PayPal Build Codecov Platforms Swift 4.2 Swift 5.1 License Mastodon

简介

是什么?

Expression 是一个 Swift 框架,用于在 Apple 和 Linux 平台上运行时评估表达式。

Expression 库分为两个部分:

  1. Expression 类,它类似于 Foundation 内置的 NSExpression 类,但对自定义运算符有更好的支持,更友好的 Swift API,以及更卓越的性能。

  2. AnyExpression,是 Expression 的扩展,处理任意类型,并为常见的类型(如 StringDictionaryArrayOptional)提供额外的内置支持。

为什么?

在许多情况下,能够运行时评估一个简单的表达式非常有用。一些示例在库包含的示例应用程序中进行了演示:

但还有其他可能的应用,例如:

(如果您发现任何其他用例,请告诉我,我会添加它们)

通常,这类计算会涉及将 JavaScript 或 Lua 等重量级解释型语言嵌入到您的应用程序中。Expression 避免了这种开销,并且更安全,因为它降低了由于任意代码注入或无限循环、缓冲区溢出等导致的崩溃风险。

Expression 快速、轻量、经过充分测试,并且完全用 Swift 编写。对于评估简单表达式而言,它比使用 JavaScriptCore 快得多(有关科学比较,请参阅 基准测试 应用程序)。

如何使用?

Expression 的工作原理是将表达式字符串解析为符号树,然后可以在运行时对其进行评估。每个符号都映射到一个 Swift 闭包(函数),该闭包在评估期间执行。有表示常见数学运算的内置函数,或者您可以提供自己的自定义函数。

虽然 Expression 类仅适用于 Double 值,但 AnyExpression 使用了一种称为 NaN boxing 的技术,通过 IEEE 浮点规范中未使用的位模式来引用任意数据。

用法

安装

Expression 类封装在单个文件中,并且所有公共内容都带有前缀或命名空间,因此您只需将 Expression.swift 文件拖到您的项目中即可使用它。如果您希望使用 AnyExpression 扩展,那么也请包含 AnyExpression.swift 文件。

如果您愿意,可以使用一个框架导入,该框架同时包含 ExpressionAnyExpression 类。您可以手动拖放安装,也可以使用 CocoaPods、Carthage 或 Swift Package Manager 自动安装。

要使用 CocoaPods 安装 Expression,请将以下内容添加到您的 Podfile 中:

pod 'Expression', '~> 0.13'

要使用 Carthage 安装,请将以下内容添加到您的 Cartfile 中:

github "nicklockwood/Expression" ~> 0.13

要使用 Swift Package Manager 安装,请将以下内容添加到您的 dependencies: 部分的 Package.swift 文件中:

.package(url: "https://github.com/nicklockwood/Expression.git", .upToNextMinor(from: "0.13.0")),

集成

要开始使用 Expression,请在您的文件顶部导入 Expression 模块:

import Expression

注意: 在 iOS 18 / macOS 15 中,Apple 添加了一个 Foundation.Expression 类,如果您在文件中导入 Foundation,它会与 Expression 库中定义的 Expression 冲突。为了解决这个问题,请改用 NumericExpression 别名。或者,如果您愿意,您可以通过在项目中本地写入以下内容来使用 NumericExpression 本地覆盖 Apple 的 Expression

typealias Expression = NumericExpression

您可以通过传递包含表达式的字符串以及(可选)以下任何或全部内容来创建 Expression 实例:

然后,您可以通过调用 evaluate() 方法来计算结果。

注意: 给定 ExpressionAnyExpression 实例的 evaluate() 函数是线程安全的,这意味着您可以从多个线程并发调用它。

默认情况下,Expression 已经实现了大多数标准数学函数和运算符,因此如果您的应用程序需要支持其他函数或变量,您只需要提供自定义符号字典。您可以混合和匹配实现,因此如果您有一些自定义常量或数组以及一些自定义函数或运算符,您可以提供单独的常量和符号字典。

以下是一些示例:

// Basic usage:
// Only using built-in math functions

let expression = Expression("5 + 6")
let result = try expression.evaluate() // 11

// Intermediate usage:
// Custom constants, variables and  and functions

var bar = 7 // variable
let expression = Expression("foo + bar + baz(5) + rnd()", constants: [
    "foo": 5,
], symbols: [
    .variable("bar"): { _ in bar },
    .function("baz", arity: 1): { args in args[0] + 1 },
    .function("rnd", arity: 0): { _ in arc4random() },
])
let result = try expression.evaluate()

// Advanced usage:
// Using the alternative constructor to dynamically hex color literals

let hexColor = "#FF0000FF" // rrggbbaa
let expression = Expression(hexColor, pureSymbols: { symbol in
    if case .variable(let name) = symbol, name.hasPrefix("#") { {
        let hex = String(name.characters.dropFirst())
        guard let value = Double("0x" + hex) else {
            return { _ in throw Expression.Error.message("Malformed color constant #\(hex)") }
        }
        return { _ in value }
    }
    return nil // pass to default evaluator
})
let color: UIColor = {
    let rgba = UInt32(try expression.evaluate())
    let red = CGFloat((rgba & 0xFF000000) >> 24) / 255
    let green = CGFloat((rgba & 0x00FF0000) >> 16) / 255
    let blue = CGFloat((rgba & 0x0000FF00) >> 8) / 255
    let alpha = CGFloat((rgba & 0x000000FF) >> 0) / 255
    return UIColor(red: red, green: green, blue: blue, alpha: alpha)
}()

请注意,evaluate() 函数可能会抛出错误。如果表达式格式错误,或者它引用了未知符号,则在评估期间会自动抛出错误。您的自定义符号实现也可能会抛出特定于应用程序的错误,如上面的颜色示例所示。

对于像第一个示例这样简单的硬编码表达式,不可能抛出错误,但是如果您接受用户输入的表达式,则必须始终确保捕获并处理错误。Expression 生成的错误消息是详细且易于人类阅读的(但未本地化)。

do {
    let result = try expression.evaluate()
    print("Result: \(result)")
} catch {
    print("Error: \(error)")
}

当使用 constantsarrayssymbols 字典时,错误消息生成由 Expression 库自动处理。如果您需要支持动态符号解码(如之前的十六进制颜色示例中),您可以使用 init(impureSymbols:pureSymbols) 初始化器,它稍微复杂一些。

init(impureSymbols:pureSymbols) 初始化器接受一对查找函数,这些函数接受一个 Symbol 并返回一个 SymbolEvaluator 函数。此接口非常强大,因为它允许您动态解析符号(例如颜色示例中的十六进制颜色常量),而无需预先创建所有可能值的字典。

对于每个符号,您的查找函数可以返回 SymbolEvaluator 函数或 nil。如果您不识别某个符号,则应返回 nil,以便可以由默认评估器处理。如果两个查找函数都不匹配该符号,并且它不是标准数学或布尔函数之一,则 evaluate() 将抛出错误。

在某些情况下,您可能识别出某个符号,但确定它是错误的,这是一个提供比 Expression 默认生成的错误消息更具体的错误消息的机会。以下示例匹配了一个 arity 为 1 的函数 bar(意味着它接受一个参数)。这将仅匹配对接受单个参数的 bar 的调用,并将忽略对零个或多个参数的调用。

switch symbol {
case .function("bar", arity: 1):
    return { args in args[0] + 1 }
default:
    return nil // pass to default evaluator
}

由于 bar 是一个自定义函数,我们知道它应该只接受一个参数,因此如果使用错误数量的参数调用它,抛出错误比返回 nil 来指示该函数不存在更有帮助。这看起来像这样:

switch symbol {
case .function("bar", let arity):
    guard arity == 1 else {
        return { _ in throw Expression.Error.message("function bar expects 1 argument") }
    }
    return { arg in args[0] + 1 }
default:
    return nil // pass to default evaluator
}

注意: 较新版本的 Expression 无论如何都可以正确报告像这样的微不足道的 arity 错误,所以这是一个稍微人为的例子,但这种方法可能对其他类型的错误有用,例如当参数超出范围或类型错误时。

符号

表达式由数字字面量和符号混合而成,符号是 Expression.Symbol 枚举类型的实例。内置的数学和布尔库定义了许多标准符号,但您可以自由定义自己的符号。

Expression.Symbol 枚举支持以下符号类型:

变量

.variable(String)

这是一个字母数字标识符,表示表达式中的常量或变量。标识符可以是字母和数字的任意序列,以字母、下划线 (_)、美元符号 ($)、at 符号 (@) 或井号/磅符号 (#) 开头。

与 Swift 类似,Expression 允许在标识符中使用 Unicode 字符,例如表情符号和科学符号。与 Swift 不同,Expression 的标识符也可能包含句点 (.) 作为分隔符,这对于命名空间很有用(如布局示例应用程序中所示)。

解析器还接受带引号的字符串作为标识符。可以使用单引号、双引号或反引号。由于 Expression 仅处理数值,因此将这些字符串标识符映射到数字取决于您的应用程序(如果您使用 AnyExpression,则会自动处理)。

与常规标识符不同,带引号的标识符可以包含任何 Unicode 字符,包括空格。换行符、引号和其他特殊字符可以使用反斜杠 () 转义。转义序列会为您解码,但外引号会被保留,因此您可以区分字符串和其他标识符。

最后,允许不带引号的标识符以单引号 (') 结尾,因为这是数学中用于指示修改值的常用表示法。标识符中任何其他位置的引号都将被视为名称的结尾。

要验证给定的字符串是否可以安全地用作标识符,您可以使用 Expression.isValidIdentifier() 方法。

运算符

.infix(String)
.prefix(String)
.postfix(String)

这些符号表示运算符。运算符可以是一个或多个字符长,并且可以包含几乎任何不与有效标识符名称冲突的符号,但有一些注意事项:

要验证给定的字符序列是否可以安全地用作运算符,您可以使用 Expression.isValidOperator() 方法。

您可以使用后/前缀变体重载现有的中缀运算符,反之亦然。消除歧义取决于运算符周围的空格(这与 Swift 使用的方法相同)。

任何有效的标识符也可以用作中缀运算符,方法是将其放在两个操作数之间;或者用作后缀运算符,方法是将其放在操作数之后。例如,在处理距离逻辑时,您可以将 mcm 定义为后缀运算符,或者使用 and 作为布尔运算符 && 的更易读的替代方案。

运算符优先级遵循标准的 BODMAS 顺序,乘法/除法优先于加法/减法。前缀运算符优先于后缀运算符,后缀运算符优先于中缀运算符。目前无法为自定义运算符指定优先级 - 它们都具有与加法/减法相同的优先级。

支持标准布尔运算符,并遵循正常的优先级规则,但需要注意的是,不支持短路(右侧参数可能不会被评估,具体取决于左侧)。解析器还将识别三元运算符 ?:,将 a ? b : c 视为具有三个参数的单个中缀运算符。

函数

.function(String, arity: Arity)

函数符号使用名称和 Arity 定义,Arity 是它期望的参数数量。Arity 类型是一个枚举,可以设置为 exactly(Int)atLeast(Int) 用于可变参数函数。给定的函数名称可以使用不同的 arity 重载多次。

注意: Arity 符合 ExpressibleByIntegerLiteral,因此对于固定 arity 函数,您可以直接写 .function("foo", arity: 2) 而不是 .function("foo", arity: .exactly(2))

通过使用函数名称,后跟括号中以逗号分隔的参数序列来调用函数。如果参数计数与任何指定的 arity 变体不匹配,则会抛出 arityError

由于函数符号必须具有名称,因此无法直接在表达式中使用匿名函数(例如,存储在变量中或由另一个函数返回的函数)。

但是,如果实现函数调用运算符 .infix("()"),则对此有语法支持,该运算符接受一个或多个参数,第一个参数被视为要调用的函数。这在 Expression(其中值都是数值)中的用途有限,但 AnyExpression 使用此方法来提供对[匿名函数(#anonymous-functions)的完全支持。

数组

.array(String)

数组符号表示可以通过索引访问的值序列。在表达式中,通过使用数组名称后跟方括号中的索引参数来引用数组符号。

将数组与 Expression 一起使用的最简单方法是通过 arrays 初始化器参数传入常量数组值。对于可变数组,您可以通过 symbols 参数返回 .array() 符号实现。

Expression 还支持 Swift 样式的数组字面量语法,如 [1, 2, 3] 和任意表达式的下标,如 (a + b)[c]。数组字面量映射到数组字面量构造函数符号 .function("[]", arity: .any),下标映射到数组下标运算符 .infix("[]")

由于 Expression 无法处理非数值类型,因此数组字面量构造函数和数组下标运算符在 Expression 中都没有默认实现,但是这两种AnyExpression 的标准符号库中实现。

性能

缓存

默认情况下,Expression 缓存已解析的表达式。表达式缓存的大小没有限制。在大多数应用程序中,这不太可能成为问题 - 表达式很小,即使您能想象到的最复杂的表达式也可能远小于 1KB,因此需要很多表达式才会导致内存压力 - 但是,如果出于某种原因您确实需要回收缓存表达式使用的内存,您可以通过调用 flushCache() 方法来完成。

Expression.flushCache())

flushCache() 方法接受一个可选的字符串参数,因此您也可以从缓存中删除特定的表达式,而无需清除其他表达式。

Expression.flushCache(for: "foo + bar"))

如果您希望对缓存进行更精细的控制,您可以预先解析表达式而不进行缓存,然后从预解析的表达式创建 Expression 实例,如下所示:

let expressionString = "foo + bar"
let parsedExpression = Expression.parse(expressionString, usingCache: false)
let expression = Expression(parsedExpression, constants: ["foo": 4, "bar": 5])

通过在上面的代码中将 usingCache 参数设置为 false,我们避免将表达式添加到全局缓存。您也可以自由地通过存储解析后的表达式并重复使用它来实现自己的缓存,这在某些情况下可能比内置缓存更有效(例如,通过避免线程管理,如果您的代码是单线程的)。

Expression.parse() 方法的第二个变体接受 String.UnicodeScalarView.SubSequence 和可选的终止分隔符字符串列表。这可以用于匹配嵌入在较长字符串中的表达式,并将字符序列的 startIndex 留在正确的位置,以便在到达分隔符后继续解析。

let expressionString = "lorem ipsum {foo + bar} dolor sit"
var characters = String.UnicodeScalarView.SubSequence(expression.unicodeScalars)
while characters.popFirst() != "{" {} // Read up to start of expression
let parsedExpression = Expression.parse(&characters, upTo: "}")
let expression = Expression(parsedExpression, constants: ["foo": 4, "bar": 5])

优化

默认情况下,表达式在可能的情况下会进行优化,以使评估更有效率。常见的优化包括用字面量值替换常量,以及当所有参数都是常量时,用结果替换纯函数或运算符。

优化器会减少评估时间,但会增加初始化时间,对于只评估一两次的表达式,这种权衡可能不值得,在这种情况下,您可以使用 options 参数禁用优化。

let expression = Expression("foo + bar", options: .noOptimize, ...)

另一方面,如果您的表达式要被评估数百或数千次,您将需要充分利用优化器来提高应用程序的性能。为了确保您充分利用 Expression 的优化器,请遵循以下准则:

标准库

数学符号

默认情况下,Expression 支持许多基本的数学函数、运算符和常量,这些函数、运算符和常量通常很有用,并且独立于任何特定的应用程序。

如果您使用自定义符号字典,您可以覆盖任何默认符号,或使用不同数量的参数(arity)重载默认函数。标准库中您未显式覆盖的任何符号仍然可用。

要显式禁用标准库中的单个符号,您可以覆盖它们并抛出异常:

let expression = Expression("pow(2,3)", symbols: [
    .function("pow", arity: 2): { _ in throw Expression.Error.undefinedSymbol(.function("pow", arity: 2)) }
])
try expression.evaluate() // this will throw an error because pow() has been undefined

如果您使用 init(impureSymbols:pureSymbols:) 初始化器,您可以通过为无法识别的符号返回 nil 来回退到标准库函数和运算符。如果您不想在表达式中提供对标准库函数的访问权限,请为无法识别的符号抛出错误,而不是返回 nil

let expression = Expression("3 + 4", pureSymbols: { symbol in
    switch symbol {
    case .function("foo", arity: 1):
        return { args in args[0] + 1 }
    default:
        return { _ in throw Expression.Error.undefinedSymbol(symbol) }
    }
})
try expression.evaluate() // this will throw an error because no standard library operators are supported, including +

以下是当前支持的数学符号:

常量

pi

中缀运算符

+ - / * %

前缀运算符

-

函数

// Unary functions

sqrt(x)
floor(x)
ceil(x)
round(x)
cos(x)
acos(x)
sin(x)
asin(x)
tan(x)
atan(x)
abs(x)
log(x)

// Binary functions

pow(x,y)
atan2(x,y)
mod(x,y)

// Variadic functions

max(x,y,[...])
min(x,y,[...])

布尔符号

除了数学之外,Expression 还支持布尔逻辑,遵循 C 约定,即零为假,任何非零值都为真。默认情况下未启用标准布尔符号,但您可以使用 .boolSymbols 选项启用它们:

let expression = Expression("foo ? bar : baz", options: .boolSymbols, ...)

与数学符号一样,可以使用 symbols 初始化器参数为给定的表达式单独覆盖或禁用所有标准布尔运算符。

以下是当前支持的布尔符号:

常量

true
false

中缀运算符

==
!=
>
>=
<
<=
&&
||

前缀运算符

!

三元运算符

?:

AnyExpression

用法

AnyExpression 的使用方式几乎与 Expression 类完全相同,但以下例外情况除外:

您可以按如下方式创建和评估 AnyExpression 实例:

let expression = AnyExpression("'hello' + 'world'")
let result: String = try expression.evaluate() // 'helloworld'

请注意字符串字面量使用单引号 (')。AnyExpression 支持单引号或双引号用于字符串字面量。两者之间没有区别,只是单引号不需要在 Swift 字符串字面量中转义。

由于 AnyExpressionevaluate() 方法具有泛型返回类型,因此您需要告诉它期望的类型。在上面的示例中,我们通过为 result 变量指定显式类型来做到这一点,但您也可以通过使用 as 运算符(不带 ! 或 ?)来做到这一点:

let result = try expression.evaluate() as String

evaluate 函数在类型方面具有一定的内置宽松性,因此,例如,如果表达式返回布尔值,但您指定 Double 作为期望的类型,则类型将自动转换,但如果它返回字符串,而您要求 Bool,则会抛出类型不匹配错误。

当前支持的自动转换是:

符号

除了添加对字符串字面量的支持之外,AnyExpression 还使用一些额外的符号扩展了 Expression 的标准库,用于处理 Optionals 和空值:

Optional 解包是自动的,因此目前不需要后缀 ?! 运算符。nil(又名 Optional.none)和 NSNull 都以相同的方式处理,以避免在使用 JSON 或 Objective-C API 数据时混淆。

==!= 这样的比较运算符也被扩展为可用于任何 Hashable 类型,并且 + 可以用于字符串连接,如上面的示例所示。

对于 array 符号,AnyExpression 可以使用任何 Hashable 类型作为索引。这意味着 AnyExpression 可以处理 Dictionary 值以及 ArrayArraySlice

字面量

如上所述,AnyExpression 支持使用带引号的字符串字面量,用单引号 (') 或双引号 (") 分隔。字符串内的特殊字符可以使用反斜杠 () 转义。

AnyExpression 支持在方括号中定义的数组字面量,例如 [1, 2, 3]['foo', 'bar', 'baz']。数组字面量可以包含值类型和/或子表达式的混合。

您还可以使用 ..<... 语法创建范围字面量。支持闭合范围、半开范围和部分范围。范围适用于 IntString.Index 值,并且可以与下标语法结合使用,用于切片数组和字符串。

匿名函数

除了普通的命名函数符号之外,AnyExpression 还支持调用匿名函数,匿名函数是 Expression.SymbolEvaluatorAnyExpression.SymbolEvaluator 类型的值,可以存储在常量中或从子表达式返回。

您可以通过使用常量值而不是 .function() 符号将匿名函数传递到 AnyExpression 中,但请注意,这种方法不允许您选择按 arity 重载具有相同名称的函数。

与函数符号不同,匿名函数不支持重载,但您可以使用函数体内的 switch 语句来实现不同的行为,具体取决于参数的数量。如果传递了不支持的参数数量,您还应该抛出 arityMismatch 错误,因为这无法自动检测到,例如:

let bar = { (args: [Any] throws -> Any in
    switch args.count {
    case 1:
        // behavior 1
    case 2:
        // behavior 2
    default:
        throw Expression.Error.arityMismatch(.function("bar", arity: 2))
    }
}

// static function foo returns anonymous function bar, which is called in the expression
let expression = AnyExpression("foo()(2)", symbols: [
    .function("foo"): { _ in bar }
])

注意: 匿名函数被假定为不纯函数,因此它们永远不符合内联条件,无论您是否使用 pureSymbols 选项。

Linux 支持

AnyExpression 在 Linux 上工作,但有以下注意事项:

示例项目

基准测试

基准测试应用程序分别使用 ExpressionAnyExpressionNSExpression 和 JavaScriptCore 的 JSContext 运行一组测试表达式。然后,它会记录解析和评估表达式所需的时间,并在表格中显示结果。

时间以微秒 (µs) 或毫秒为单位显示。每个类别中最快的结果以绿色显示,最慢的结果以红色显示。

为了获得准确的结果,基准测试应用程序应在真实设备上的发布模式下运行。您可以下拉表格以刷新测试结果。测试在主线程上运行,因此如果显示在刷新时短暂锁定,请不要感到惊讶。

在我自己的测试中,Expression 始终是最快的实现,而 JavaScriptCore 始终是最慢的实现,无论是对于初始设置还是对于上下文初始化后的评估。

计算器

关于这个例子没什么好说的。这是一个计算器。您可以在其中键入数学表达式,它将评估它们并生成结果(或者如果键入的内容无效,则会生成错误)。

颜色

颜色示例演示了如何使用 AnyExpression 创建(大部分)符合 CSS 标准的颜色解析器。它接受包含命名颜色、十六进制颜色或 rgb() 函数调用的字符串,并返回 UIColor 对象。

布局

有趣的地方来了:布局示例演示了一个粗糙但可用的布局系统,该系统支持视图坐标的任意表达式。

它在概念上类似于 AutoLayout,但有一些重要的区别:

示例视图的默认布局值已在 Storyboard 中设置,但您可以通过点击视图并键入新值在应用程序中实时编辑它们。

以下是一些需要注意的事项:

这只是一个玩具示例,但如果您喜欢这个概念,请查看 Github 上的 Layout 框架,它将这个想法提升到了一个新的水平。

REPL(交互式解释器)

Expression REPL(读取-评估-打印循环)是一个用于评估表达式的 Mac 命令行工具。与计算器示例不同,REPL 基于 AnyExpression,因此它允许使用任何可以表示为 Expression 语法中的字面量的类型 - 而不仅仅是数字。

您在 REPL 中键入的每一行都将独立评估。要在表达式之间共享值,您可以使用标识符名称后跟 = 然后是表达式来定义变量,例如:

foo = (5 + 6) + 7

然后,命名变量(在本例中为“foo”)可在后续表达式中使用。

鸣谢

Expression 框架主要是 Nick Lockwood 的工作成果。

贡献者完整列表