TypeScript实现对数值范围的模式匹配,尝试替代if else

TypeScript实现对数值范围的模式匹配,尝试替代if else

省流:不如if else一把梭

起因

我写代码经常会用到一些if else的替代方案,其中我个人很喜欢的一种方案是策略模式。例如这么一个简单例子:

function a() {/* do something */}
function b() {/* do something */}
function c() {/* do something */}

function foo(x) {
    if (x === 'a') {
        a()
    }
    else if (x === 'b') {
        b()
    }
    else if (x === 'c') {
        c()
    }
}

如果改用策略模式就可以这样写:

function a() {/* do something */}
function b() {/* do something */}
function c() {/* do something */}

function foo(x) {
    const pattern = {a, b, c}
    pattern[x]()
}

其实就是将每个分支都通过对象 key: value 的方式保存,然后用查字典的方式执行相应的操作。

如果每个分支都是对具体数值的if判断,那就都可以用策略模式轻松替代。

但是,如果遇到对数值范围的判断,想要实现策略模式就比较困难了,例如:

function a() {/* do something */}
function b() {/* do something */}
function c() {/* do something */}

function foo(x) {
    if (x > 0 && x <= 10) {
        a()
    }
    else if (x > 10 && x <= 30) {
        b()
    }
    else if (x > 30 && x <=100) {
        c()
    }
}

这种情况下,想要写一个覆盖范围内所有值的对象是不可能的,因此无法向上面例子那样,简单通过对象 key: value 的方式替代if else。

如果各个范围的开、闭区间是规整的,例如上面这个例子,每个范围都是“前开后闭”,这种特例我们可以用数组来替代if else:

function a() {/* do something */}
function b() {/* do something */}
function c() {/* do something */}

function foo(x) {
    const nums = [[10, a], [30, b], [100, c]]
    const i = nums.find(num => x <= num[0])
    nums[i][1]()
}

但是,更多情况下,每个范围的开、闭区间是不规整的,这种做法的通用性太差。

于是,我在想,能不能实现一个对所有开、闭区间数值范围的模式匹配呢,在我的想象中,它的最终用法应该是这样的:

function match_range(n, pattern) {
    /* do something */
}

const result = match_range(20, {
    "[0,10)": 10,
    '10': (n) => n * 2,
    "(10,30]": (start, end) => start + end,
    "(30,100]": (start, end) => end - start,
    "101": 101,
})

pattern是模式匹配规则,它是一个对象,每个字段的值可以是函数,也可以是其他值。如果是其他值就直接取值返回给result,如果是函数,则调用并返回值给result,参数是该模式的数值范围。

这里我采用数集的写法,用小括号表示开区间,中括号表示闭区间,要求调用者在使用时,每个区间不应该有任何重叠,单独一个数则表示相等。

感觉实现起来不是很难,对吧?

实现Range类

首先写一个Range类,用于解析形如"(10,30]"格式的字符串:

/**
 * 数字区间类,构造一个形如 (4, 10] 的区间对象。
 */
class Range {

    start: number // 起始值
    end: number // 结束值
    s_open: boolean // 起始值是否是开区间
    e_open: boolean // 结束值是否是开区间
    equal: boolean // 两个值是否相等

    // 构造函数的参数可以是字符串或数字,这里假设调用者传入的都是合法值,因此未做进一步验证,合法的值有三类:
    // 1. 纯数字,例如: 20
    // 2. 纯数字字符串,例如: "30"
    // 3. 区间字符串,例如 "(0, 40]"
    // 不能是JS数组,因为这样将无法判断开闭区间,虽然可以默认起始值是闭区间、结束值是开区间,但这样做很容易给调用者带来困惑。
    constructor(r: string | number) {
        // 如果参数是数字
        if (typeof r === 'number' || /^\d+$/.test(r)) {
            r = Number(r)

            this.start = r
            this.end = r
            this.equal = true
            this.s_open = false
            this.e_open = false
        }
        // 如果参数是区间
        else {
            const [start, end] = r.slice(1, -1).split(',')

            this.start = Number(start)
            this.end = Number(end)

            // 如果起始值大于结束值,理论上可以交换处理,但由于涉及开、闭区间,这样做不一定合理,所以直接抛出错误。
            if (this.start > this.end) {
                throw new Error(`Invalid range: ${r}, start is greater than end.`)
            }

            this.equal = this.start === this.end

            this.s_open = r[0] === '('
            this.e_open = r[r.length - 1] === ')'
        }
    }

    // 判断一个数是否位于区间内
    is_between(num: number) {
        if (this.equal) {
            return num === this.start
        }
        if (this.s_open) {
            if (this.e_open) {
                return num > this.start && num < this.end
            }
            else {
                return num > this.start && num <= this.end
            }
        }
        else if (this.e_open) {
            return num >= this.start && num < this.end
        }
        else {
            return num >= this.start && num <= this.end
        }
    }

}

JS实现数值范围模式匹配

有了Range类,就可实现对数值范围的模式匹配了,我先用JS写一遍:

function match_range(n, pattern) {
    // 遍历模式对象
    for (const p in pattern) {
        // 创建Range对象
        const range = new Range(p)
        // 判断数值范围
        if (range.is_between(n)) {
            const handle = pattern[p]
            // handle可能是函数,也可能是值,如果是函数就传递区间的起始数值给它调用,如果不是函数就直接取值返回。
            return typeof handle === 'function' ? handle(range.start, range.end) : handle
        }
    }
    // 没匹配上返回null。
    return null
}

const result= match_range(4, {
    "[0,10)": 10,
    '10': (n) => n * 2,
    "(10,30]": (s, e) => s + e,
    "(30,100]": (s, e) => e - s,
    "101": 101,
})

加一点简单的类型体操

接下来要对上述match_range函数实现TS类型提示,限制其参数类型和返回值类型:

// 判断是否是函数
function is_callable<T extends Function>(target: any): target is T {
    return typeof target === 'function'
}

function match_range<T>(n: number, pattern: Record<string, T | ((start: number, end: number) => T)>) {
    for (const p in pattern) {
        const range = new Range(p)
        if (range.is_between(n)) {
            const handle = pattern[p]
            return is_callable(handle) ? handle(range.start, range.end) : handle
        }
    }
    return null
}

// 这里每个模式的值类型是 number | (start: number, end: number) => number
// result的类型是 number | null,
const result = match_range(4, {
    "[0,10)": 10,
    '10': (n) => n * 2,
    "(10,30]": (s, e) => s + e,
    "(30,100]": (s, e) => e - s,
    "101": 101,
})

这里把is_callable专门拎出来,是为了通过is来断言handle是可调用类型(即函数)。

完整代码

完整代码如下,我给Range加了点额外的功能,主要是验证参数的合法性,另外我将match_range的回调参数修改为了Range对象,并允许给pattern添加默认字段_,用于兜底:

class Range {
static REG_1 = /^(\(|\[)\s*(-?\d+)\s*,\s*(-?\d+)\s*(\)|\])$/
static REG_2 = /^\d+$/
_r: string
start: number
end: number
s_open: boolean
e_open: boolean
unique: boolean
constructor(r: string | number) {
if (typeof r === 'number' || Range.REG_2.test(r)) {
r = Number(r)
this.start = r
this.end = r
this.unique = true
this.s_open = false
this.e_open = false
this._r = `[${r}, ${r}]`
}
else {
if (!Range.REG_1.test(r)) {
throw new Error(`Invalid range: ${r}.`)
}
this._r = r
const [start, end] = r.slice(1, -1).split(',')
this.start = Number(start)
this.end = Number(end)
if (this.start > this.end) {
throw new Error(`Invalid range: ${r}, start is greater than end.`)
}
this.unique = this.start === this.end
this.s_open = r[0] === '('
this.e_open = r[r.length - 1] === ')'
}
}
toString() {
return this._r
}
static valid(origin: string | number) {
return typeof origin === 'number' || Range.REG_1.test(origin) || Range.REG_2.test(origin)
}
is_between(num: number) {
if (this.unique) {
return num === this.start
}
if (this.s_open) {
if (this.e_open) {
return num > this.start && num < this.end
}
else {
return num > this.start && num <= this.end
}
}
else if (this.e_open) {
return num >= this.start && num < this.end
}
else {
return num >= this.start && num <= this.end
}
}
equals(other: Range) {
return this.start === other.start && this.end === other.end && this.s_open === other.s_open && this.e_open === other.e_open
}
}
function is_callable<T extends Function>(target: any): target is T {
return typeof target === 'function'
}
function match_range<T>(n: number, pattern: Record<string, T | ((range: Range) => T)>) {
for (const p in pattern) {
if (p === '_') {
continue
}
const range = new Range(p)
if (range.is_between(n)) {
const handle = pattern[p]
return is_callable(handle) ? handle(range) : handle
}
}
if ('_' in pattern) {
const handle = pattern._
return is_callable(handle) ? handle(new Range(n)) : handle
}
return null
}

总结

最后回到替代if else的话题,上述做法实际上依旧是在内部通过if else来实现的,与策略模式不完全是一回事,只不过是通过封装换了一种写法,但话说回来,它未必就比普通的if else更好,一是它运行效率更低,可读性存疑,而且从通用性来说,如果遇到多个范围合并判断的情形,这种做法就废了,即便通过改进,最终实现了对多个范围的匹配,衡量一下实现它的时间成本、效率开销和它的使用价值,最终结论很可能是:我还不如if else一把梭了。

原文链接:https://juejin.cn/post/7354075693946241087 作者:Taiyuuki

(0)
上一篇 2024年4月6日 下午4:52
下一篇 2024年4月6日 下午5:02

相关推荐

发表回复

登录后才能评论