本文的思路是从一个没有考虑 clean code 原则的函数开始,然后以清晰明了的方式进行重写。整个过程都将遵循测试驱动原则。
本文的目的是验证一种能够应用于重构老代码库并提高其代码质量的方法。
我们将创建一组函数,以不同的抽象级别分解原始函数算法,从老代码中汲取灵感,并尽可能复用它。
另外,我们将跟踪每一段新引入的功能代码都分别覆盖了老代码中的哪几行,以确保我们实现了所有功能。
首先我们来阅读一下这段原始代码:用于自然排序的高性能比较算法。
const natOrdCmp2 = (a,b) => {
let i
for (i=0; i<a.length; i++) {
const ai = a.charCodeAt(i)
const bi = b.charCodeAt(i)
if (!bi) return 1
if (isDigit(ai) && isDigit(bi)) {
const k = skipDigit(a,i)
const m = skipDigit(b,i)
if (k > m) return 1
if (k < m) return -1
// Same number of digits! Compare them
for (let j=i; j < k; j++) {
const aj = a.charCodeAt(j)
const bj = b.charCodeAt(j)
if (aj < bj) return -1
if (aj > bj) return 1
}
// Same number! Update the number of compared chars
i = k - 1
} else {
// Compare alphabetic chars.
if (ai > bi) return 1
if (ai < bi) return -1
}
}
return b.charCodeAt(i) ? -1 : 0
}
毫无疑问,这段代码非常晦涩难懂。而且它处理不了包含前导零的数字部分。当然要处理它们还是非常容易的,并且在进行 clean code 重构之后会更加容易!
我理想的高阶函数应该长这个样子:
const natural_sort_comparison = (a: string, b: string) =>
compare_chars_or_numbers_of(
a,
with_corresponding_char_or_number_of(b)
)
这与维基百科中对自然排序的定义非常接近:
在计算机科学中,自然测序(或自然序、自然排序,英文:Natural sort order)是一种字符串排序方式,其在字母测序的基础上,将字符串中的多个数字字符视为整体,并按数值方式进行排序。
它确切地反映了自然排序算法的本质,但这里有一个技术难点:它需要使用一个闭包函数,将 b 作为参数传递进去以有效执行任务。
可能有人会认为使用闭包让这段代码变得晦涩难懂。所以我会先选择一个更简单、更不优雅的版本:
const natural_sort_comparison = (a: string, b: string) =>
compare_chars_or_numbers(a, b)
但我的最终解决方案将使用我的第一个想法,因为我并不认为优雅和复杂会降低代码清晰度。
在下文中,我将使用这三个常量:
const EQUAL = 0
const SMALLER = -1
const BIGGER = 1
我们的第一个测试集将验证遍历字符串 a 中字符的代码正确“移植”到新代码中。为了达到这个简单的目的,测试字符串只包含字母字符,并且每个字符串都不是另一个字符串的前缀。
test('Basic string comparisons', () => {
expect(natural_sort_comparison('abc', 'bc')).toBe(SMALLER)
expect(natural_sort_comparison('bc', 'abc')).toBe(BIGGER)
expect(natural_sort_comparison( 'abcde', 'abcde')).toBe(EQUAL)
))
我们会覆盖原始代码中的第 2,3,4,5,24,25 行以及 28 行的部分功能。
具体代码如下:
const compare_chars_or_numbers = (a, b) => {
let charIndex = 0
while ( charIndex < a.length ) {
const comparisonResult =
compare_one_char_or_number(a, b, charIndex)
if (comparisonResult !== EQUAL) return comparisonResult
charIndex++
}
return EQUAL
}
const compare_one_char_or_number = (a, b, charIndex) => {
const aCode = a.charCodeAt(charIndex)
const bCode = b.charCodeAt(charIndex)
return aCode - bCode
}
我用 while 替换掉了 for 循环,因为我想在最终的代码里将第 3 行的 for 循环增量和第 21 行的增量合并为一条指令。
下一步就是对处理前缀字符的解决方案进行重组。
添加的测试用例如下:
test('Strings that are prefixed by the other', () => {
expect(natural_sort_comparison('abc', 'abcd')).toBe(SMALLER)
expect(natural_sort_comparison('abcd', 'abc')).toBe(BIGGER)
))
第 6 行和第 28 行的判断应该放到 compare_one_char_or_number 函数内部,另外我们还需要找到一种方法将第 28 行的判断包含在遍历参数 a 的过程中。
const compare_chars_or_numbers = (a, b) => {
let charIndex = 0
const maxComparisonChars = a.length + OneMoreToCheckIfAisPrefixOfB
while ( charIndex < maxComparisonChars ) {
const comparisonResult =
compare_one_char_or_number(a, b, charIndex)
if (comparisonResult !== EQUAL) return comparisonResult
charIndex++
}
return EQUAL
}
const OneMoreToCheckIfAisPrefixOfB = 1
现在,该函数必须处理一个可能超过字符串长度的 charIndex 参数。不过问题不大,因为在这种情况下 chartAt(i) 会返回 NaN。需要注意的是,比较两个相等的字符串会导致 charIndex 超过两个字符串的大小。
const compare_one_char_or_number = (a, b, charIndex) => {
const aCode = a.charCodeAt(charIndex)
const bCode = b.charCodeAt(charIndex)
return compare_char_codes(aCode, bCode)
}
const compare_char_codes = (aCode, bCode) => {
if (are_strings_equal(aCode, bCode)) return EQUAL
if (is_the_string_prefix_of_the_other(aCode)) return SMALLER
if (is_the_string_prefix_of_the_other(bCode)) return BIGGER
return aCode - bCode
}
const is_the_string_prefix_of_the_other =
charCode => isNaN(charCode)
const are_strings_equal =
(aCode, bCode) => isNaN(aCode) && isNaN(bCode)
下一步是处理带有数字部分的字符串。首先要考虑的是,compare_one_character_or_one_number现在可以一次比较多个字符(数字部分可以由多个数值组成)。 在子字符串相等的情况下,比较的字符数用于增加 compare_chars_or_numbers 中的循环计数器,因此该函数必须返回两个值给调用者。 在这种情况下,常用的做法(在 React hooks 中广泛使用)是返回一个数组并使用 ES6 语法来解构赋值:
const compare_chars_or_numbers = (a, b) => {
let charIndex = 0
const maxComparisonChars = a.length + OneMoreToCheckIfAisPrefixOfB
while ( charIndex < maxComparisonChars ) {
const [comparisonResult, comparedChars] =
compare_one_char_or_number(a, b, charIndex)
if (comparisonResult !== EQUAL) return comparisonResult
charIndex += comparedChars
}
return EQUAL
}
const compare_one_char_or_number = (a, b, charIndex) => {
const aCode = a.charCodeAt(charIndex)
const bCode = b.charCodeAt(charIndex)
const comparedChars = 1
return [compare_char_codes(aCode, bCode), comparedChars]
}
注意,直到两个子字符串相等之前 charIndex 会一直递增,但当它们不相等时,comparedChars 不会被使用。
重新运行单元测试以确保这些修改不会导致代码出现错误。
现在我们准备为包含数字的字符串编写测试用例。从 1 位数字开始。
test('Strings that are prefixed by the other', () => {
expect(natural_sort_comparison('ab2c1', 'ab2c2')).toBe(SMALLER)
expect(natural_sort_comparison('ab2c2', 'ab2c1')).toBe(BIGGER)
))
函数 compare_one_char_or_number 应该区分标准字符和数字序列:
const compare_one_char_or_number = (a, b, charIndex) => {
const aCode = a.charCodeAt(charIndex)
const bCode = b.charCodeAt(charIndex)
if (is_digit(aCode) && is_digit(bCode)) {
return compare_digits(a, b, charIndex)
}
const comparedChars = 1
return [compare_char_codes(aCode, bCode), comparedChars]
}
const compare_digits = (a, b, charIndex) => {
return [a - b, 1]
}
const is_digit = charCode => charCode>=48 && charCode<=57
注意,is_digit(NaN) 返回值为 false,因此处理前缀及判断字符串相等的逻辑只能写在 compare_char_codes 内部。
compare_one_char_or_number 函数中的两个返回语句存在这么个问题:我们混淆了两个不同级别的抽象:谁负责返回这两个函数值?我们无法从 compare_digits 函数中分离出这个职责,因此即使在处理字母字符时,我们也得将其委托出去。
const compare_one_char_or_number = (a, b, charIndex) => {
const aCode = a.charCodeAt(charIndex)
const bCode = b.charCodeAt(charIndex)
if (is_digit(aCode) && is_digit(bCode)) {
return compare_digits(a, b, charIndex)
}
return compare_one_char_code_pair(aCode, bCode)
}
const compare_one_char_code_pair = (aCode, bCode) => {
const comparedChars = 1
return [compare_char_codes(aCode, bCode), comparedChars]
}
最终版的 compare_one_char_or_number 函数同样覆盖了老代码中的第 7 行。
下一步就是比较不同位数的数字。
test('Strings that are prefixed by the other', () => {
expect(natural_sort_comparison('abc2', 'abc11')).toBe(SMALLER)
expect(natural_sort_comparison('abc111', 'abc21')).toBe(BIGGER)
))
对于数字部分来说,位数多的数值就大。因此,只需计算两个字符串中连续数字的数量并比较计数器即可。
const compare_digits = (a, b, charIndex) => {
const aDigits = number_of_consecutive_digits(a, charIndex)
const bDigits = number_of_consecutive_digits(b, charIndex)
if (aDigits > bDigits) return [BIGGER]
if (aDigits < bDigits) return [SMALLER]
// cannot be here for the moment
}
const number_of_consecutive_digits = (str, startIndex) => {
let lastIndex
for (lastIndex=startIndex+1; lastIndex<str.length; lastIndex++)
if (!is_digit(str.charCodeAt(lastIndex)))
return lastIndex - startIndex
return lastIndex - startIndex
}
好了,现在最后一步就是比较包含相同位数数字部分的字符串。
test('Strings that are prefixed by the other', () => {
expect(natural_sort_comparison('abc12', 'abc12')).toBe(EQUAL)
expect(natural_sort_comparison('abc11', 'abc12')).toBe(SMALLER)
expect(natural_sort_comparison('abc13', 'abc12')).toBe(BIGGER)
))
如果数字部分的位数相同,只需要比较数字的 char code 顺序即可。
const compare_digits = (a, b, charIndex) => {
const aDigits = number_of_consecutive_digits(a, charIndex)
const bDigits = number_of_consecutive_digits(b, charIndex)
if (aDigits > bDigits) return [BIGGER]
if (aDigits < bDigits) return [SMALLER]
return compare_equal_length_numbers(a, b, charIndex, aDigits)
}
const compare_equal_length_numbers =
(a, b, startIndex, numberOfDigits) => {
for (let charIndex = startIndex;
charIndex < startIndex + numberOfDigits;
charIndex++) {
const aCode = a.charCodeAt(charIndex)
const bCode = b.charCodeAt(charIndex)
if (aCode < bCode) return [SMALLER]
if (aCode > bCode) return [BIGGER]
}
return [EQUAL, numberOfDigits]
}
compare_equal_length_numbers 覆盖了老代码中的13-18行,compare_digits 覆盖了8-11行。
重构到此就完成了。不过在展示重构后的完整代码之前,别忘了我在最开始是怎么说的……我更倾向于优雅的代码实现,即使它复杂一些。毕竟,闭包也是语言特性的一部分……
const natural_sort_comparison = (a: string, b: string) =>
compare_chars_or_numbers_of(
a,
with_corresponding_char_or_number_of(b)
)
const compare_chars_or_numbers_of = (a, compare_with_b) => {
let charIndex = 0
const maxComparisonChars = a.length + OneMoreToCheckIfAisPrefixOfB
while ( charIndex < maxComparisonChars ) {
const [comparisonResult, comparedChars] =
compare_with_b(a, charIndex)
if (comparisonResult !== EQUAL) return comparisonResult
charIndex += comparedChars
}
return EQUAL
}
const with_corresponding_char_or_number_of = b => {
const compare_with_b = (a, charIndex) =>
compare_one_char_or_number(a, b, charIndex)
return compare_with_b
}
我只改变了主函数的名称和签名,并在其内部将对 compare_with_b 的调用进行了更改:这是一个处理参数 b 的闭包,它会使用所有必要的参数调用 compare_one_char_or_number。
晨曦之中保留了一丝黑暗,它允许我们对顶级函数进行表达式调用。
const EQUAL = 0
const SMALLER = -1
const BIGGER = 1
const OneMoreToCheckIfAisPrefixOfB = 1
const natural_sort_comparison = (a: string, b: string) =>
compare_chars_or_numbers_of(a, with_corresponding_char_or_number_of(b))
const compare_chars_or_numbers_of = (a, compare_with_b) => {
let charIndex = 0
const maxComparisonChars = a.length + OneMoreToCheckIfAisPrefixOfB
while ( charIndex < maxComparisonChars ) {
const [comparisonResult, comparedChars] = compare_with_b(a, charIndex)
if (comparisonResult !== EQUAL) return comparisonResult
charIndex += comparedChars
}
return EQUAL
}
const with_corresponding_char_or_number_of = b => {
const compare_with_b = (a, charIndex) => compare_one_char_or_number(a, b, charIndex)
return compare_with_b
}
const compare_one_char_or_number = (a, b, charIndex) => {
const aCode = a.charCodeAt(charIndex)
const bCode = b.charCodeAt(charIndex)
if (is_digit(aCode) && is_digit(bCode)) {
return compare_digits(a, b, charIndex)
}
return compare_one_char_code_pair(aCode, bCode)
}
const is_digit = charCode => charCode>=48 && charCode<=57
const compare_one_char_code_pair = (aCode, bCode) => {
const comparedChars = 1
return [compare_char_codes(aCode, bCode), comparedChars]
}
const compare_char_codes = (aCode, bCode) => {
if (are_strings_equal(aCode, bCode)) return EQUAL
if (is_the_string_prefix_of_the_other(aCode)) return SMALLER
if (is_the_string_prefix_of_the_other(bCode)) return BIGGER
return aCode - bCode
}
const is_the_string_prefix_of_the_other = charCode => isNaN(charCode)
const are_strings_equal = (aCode, bCode) => isNaN(aCode) && isNaN(bCode)
const compare_digits = (a, b, charIndex) => {
const aDigits = number_of_consecutive_digits(a, charIndex)
const bDigits = number_of_consecutive_digits(b, charIndex)
if (aDigits > bDigits) return [BIGGER]
if (aDigits < bDigits) return [SMALLER]
return compare_equal_length_numbers(a, b, charIndex, aDigits)
}
const number_of_consecutive_digits = (str, startIndex) => {
let lastIndex
for (lastIndex=startIndex+1; lastIndex<str.length; lastIndex++)
if (!is_digit(str.charCodeAt(lastIndex)))
return lastIndex - startIndex
return lastIndex - startIndex
}
const compare_equal_length_numbers = (a, b, startIndex, numberOfDigits) => {
for (let charIndex = startIndex; charIndex < startIndex + numberOfDigits; charIndex++) {
const aCode = a.charCodeAt(charIndex)
const bCode = b.charCodeAt(charIndex)
if (aCode < bCode) return [SMALLER]
if (aCode > bCode) return [BIGGER]
}
return [EQUAL, numberOfDigits]
}
export default natural_sort_comparison
虽然代码行数翻了一倍(考虑到老代码中没有包含 skipDigit 函数),但我们添加了大量细节,现在这份代码可以说是“像诗一样优雅”了。
我很好奇你对这次重构有什么看法,尤其是最后的“黑暗片段”。期待你的评论。