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

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

省流:不如if else一把梭

起因

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

js 复制代码
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()
    }
}

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

js 复制代码
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判断,那就都可以用策略模式轻松替代。

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

ts 复制代码
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:

js 复制代码
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][0]()
}

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

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

js 复制代码
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]"格式的字符串:

ts 复制代码
/**
 * 数字区间类,构造一个形如 (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写一遍:

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类型提示,限制其参数类型和返回值类型:

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添加默认字段_,用于兜底:

ts 复制代码
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一把梭了。

相关推荐
Pedantic1 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘2 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆2 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
浏览器工程师3 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆3 小时前
VSCode自动格式化三要素
前端
爱勇宝4 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen4 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user20585561518136 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端
LiaCode6 小时前
Redis 在生产项目的使用
前端·后端