阅读 588

浅谈 Swift 泛型元编程——建构一个在编译时就确保了安全的 VFL 助手库

前言

什么是在你选择一门编程语言的时候最能左右你决定的事?

有人会说,要写的越少,语言越好。(并不是,PHP 是最好的语言。)

好吧,这也许是真的。但是要写的少并不是一个可以在何时何地都得到同一结果的可以量化的指标。根据你任务的不同,代码的行数也在上下浮动。

我认为最好的方法是考察编程语言有多少 primitives(元语)。

对于一些老式的编程语言而言,他们有的没有多维数组。这意味着数组并不能包含他们自己。这束缚了一些开发者来发明某些具有递归性质的数据结构,同时也限制了语言的表达性。语言的表达性,形式化地讲,就是语言的计算能力

但是我刚刚提到的这个数组的例子仅仅只和运行时计算能力有关。编译时计算能力又是怎样呢?

好的。像 C++ 这样具备显示编译过程以及一些「代码模板」设施的语言是具有进行某些编译时计算的能力。他们通常是收集源代码的碎片,然后将他们组织成一段新的代码。你也许已经听过一个大词了:「元编程」。是的,这就是元编程(但是是在编译时)。而这些语言也包含了 C 和 Swift。

C++ 元编程依赖于模板。在 C 中,元编程依赖于一个来自 libobjcext 的特殊头文件 metamacros.h。在 Swift 中,元编程依赖于泛型。

尽管你可以在这三种语言中做编译时元编程,其能力又是不同的。因为已经有很多文章谈论 C++ 模板为什么是图灵完备(一种计算能力的度量,你可以简单认为它就是「啥都能算」)的了,我不想在这上面浪费我的实践。我要讨论的是 Swift 中的泛型元编程,以及要给 C 中的 metamacros.h 作一个简单的介绍。这两种语言的编译时元编程能力都比 C++ 要弱。他们仅仅只能够实现一个 DFA(确定性自动机,另一种计算能力的度量。你可以简单的认为它就是「能计算有限的模式」)上限的编译时计算设施。


案例研究: 在编译时就确保了安全的 VFL

我们有许多 Auto Layout 助手库:Cartography, Masonry, SnapKit... 但是,他们真的好吗?要是有一个 Swift 版本的 VFL 能在编译时就确保正确性而且能够和 Xcode 的代码补全联动如何?

老实说,我是一个 VFL 爱好者。你可以用一行代码就对很多视图进行布局。要是是 Cartography 或者 SnapKit,早就「王婆婆的裹脚又长又臭」了。

由于原版的 VFL 对于现代 iOS 设计的支持上有一点问题,这主要表现在不能和 layout guide 合作上,你也许也想要我们马上要实现的这套 API 能够支持 layout guide。

最后,在我的生产代码中,我构建了如下的可以在编译时就确保了安全的并且支持 layout guide 的 API。

// 创建布局约束并且装置入视图

constrain {
    withVFL(H: view1 - view2)
    
    withVFL(H: view.safeAreaLayoutGuide - view2)
    
    withVFL(H: |-view2)
}

// 仅仅创建布局约束

let constraints1 = withVFL(V: view1 - view2)

let constraints2 = withVFL(V: view3 - view4, options: .alignAllCenterY)
复制代码

想象一下在 Cartography 或者 SnapKit 中构建等效的事情需要多少行代码?想知道我怎么构建出来的了吗?

让我来告诉你。

语法变形

如果我们将原版的 VFL 语法导入到 Swift 源代码中并且去除掉字符串字面量的引号,你很快就会发现一些在原版 VFL 中所使用的字符像 [, ], @, () 是不能在 Swift 中用进行操作符重载的。于是我对原版 VFL 语法做了一些变形:

// 原版 VFL: @"|-[view1]-[view2]"
withVFL(H: |-view1 - view2)

// 原版 VFL: @"[view1(200@20)]"
withVFL(H: view1.where(200 ~ 20))

// 原版 VFL: @"V:[view1][view2]"
withVFL(V: view1 | view2)

// 原版 VFL: @"V:|[view1]-[view2]|"
withVFL(V: |view1 - view2|)

// 原版 VFL: @"V:|[view1]-(>=4@200)-[view2]|"
withVFL(V: |view1 - (>=4 ~ 200) - view2|)
复制代码

探索实现

如何达成我们的设计?

一个来自直觉的答案就是使用操作符重载。

是的。我已经在我的生产代码中用操作符重载达成了我们的设计。但是操作符重载在这里是如何工作的?我是说,为什么操作符重载可以承载我们的设计?

在回答这个问题之前,让我们看一些例子。

withVFL(H: |-view1 - view2 - 4)
复制代码

上例是一个是一个不应该被编译器接受的非法输入。相应的原版 VFL 如下:

@"|-[view1]-[view2]-4"
复制代码

我们可以发现在 4 之后缺少了一个视图,或者一个 -|

我们希望我们的系统可以通过让编译器接受一段输入来把控正确的输入,通过让编译器拒绝一段输入来把控错误的输入(因为这就是编译时就确保了安全的所隐含的意思)。这背后的秘密并不是由一个抬头是「高级软件开发工程师」的神秘工程师施放的黑魔法,而是简单的通过匹配用户输入与已经定义好了的函数来接受用户输入,通过失配用户输入和已经定义好了的函数来拒绝用户输入。

比如,就像上例中 view1 - view2 拿部分所示,我们可以设计如下函数来把控他。

func - (lhs: UIView, rhs: UIView) -> BinarySyntax {
    // Do something really combine these two views together.
}
复制代码

如果我们将上述代码块中的 UIViewBinarySyntax 看作两个状态,那么我们就可以在我们的系统中引入状态转移了,而状态转移的方法就是操作符重载。

朴素的状态转移

知道了通过操作符重载引入状态转移也许能解决我们的问题,我们可以呼一口气了。

但是……这个解决方案下我们要创建多少种类型?

你也许不知道的是,VFL 可以被表达为一个 DFA。

是的。因为如[, ], () 这样的递归文本在 VFL 中并不是真正的递归文本(在正确的 VFL 中他们只能出现一层并且无法嵌套),一个 DFA 就可以表述出 VFL 的所有可能的输入集合。

于是我绘制了一个 DFA 来模拟我们设计中的状态转移。要小心。在这张图中我没有把 layout guide 放进去。加入 layout guide 只会让这个 DFA 变得更复杂。

了解更多的关于递归和 DFA 的朴实的简介你可以看看这本书计算的本质:深入剖析程序和计算机

自动机
自动机

上图中, |pre 表示一个前缀 | 操作符,同样的,|post 表示一个后缀 | 操作符。两个圆圈表示接受,单个圆圈表示接收。

数我们要创建的类型的数目是一个复杂的任务。由于有双目操作符 |-,还有单目操作符 |-, -|, |prefix|postfix,计数方法在这两种操作符中是不同的。

一个双目操作符消耗两次状态转移,而一个单目操作符消耗一次。每一个操作符都将创建一个新的类型。

因为这个计数方法本身实在太复杂了,我宁愿想想别的方法……

多状态的状态转移

我是通过死命测试可能的输入字符以测试一个状态是否接受他们来画出上面这个 DFA 图的。这将所有的一切都映射到了一个一个维度上。也许我们可以通过在多个维度对问题进行抽象来创造一种更加清澈的表达。

在开始深入探索前,我们不得不获取一些关于 Swift 操作符结合性的一些基础知识。

结合性是一个操作符(严格来讲,双目操作符。就是像 - 那样连结左手边算子和右手边算子的操作符)在编译时期,确定编译器选择在哪边构建语法树的一个性质。Swift 默认的操作符结合性是向左。这意味着编译器更加倾向于在一个操作符的左手边构建语法树。于是我们可以知道,对于一个由向左结合的操作符生成的语法树,其在视觉上是向左倾斜的。

首先让我们来看看几个最简单的表达式:

// 应该接受
withVFL(H: view1 - view2)

// 应该接受
withVFL(H: view1 | view2)

// 应该接受
withVFL(H: |view1|)

// 应该接受
withVFL(H: |-view1-|)
复制代码

他们的语法树如下:

简单表达式
简单表达式的语法树

然后我们可以将情况分为两类:

  • view1 - view2, view1 | view2 这样的双目表达式。

  • |view1, view1-| 这样的单目表达式。

这使我们直觉地创建了两种类型:

struct Binary<Lhs, Rhs> { ... }

func - <Lhs, Rhs>(lhs: Lhs, rhs: Rhs) -> Binary { ... }

func | <Lhs, Rhs>(lhs: Lhs, rhs: Rhs) -> Binary { ... }

struct Unary<Operand> { ... }

prefix func | <Operand>(operand: Operand) -> Unary { ... }

postfix func | <Operand>(operand: Operand) -> Unary { ... }

prefix func |- <Operand>(operand: Operand) -> Unary { ... }

postfix func -| <Operand>(operand: Operand) -> Unary { ... }
复制代码

但是这够了吗?

Syntax Attribute

你马上会发现,我们可以将任何东西代入 BinaryLhs 或者 Rhs,或者 UnaryOperand 中。我们需要做一些限制。

典型地说,像 |-, -|, |prefix, |postfix 这种输入只应该出现在表达式首尾两端。因为我们也希望支持 layout guide(如 safeAreaLayoutGuide),而 layout guide 也只应该出现在表达式首尾两端,我们还需要对这些东西做一些限制来确保他们仅仅出现在表达式的两端。

|-view-|
|view|
复制代码

另外,像 4, >=40 这种输入只应该和前驱和后继视图/父视图或者 layout guide 配合出现。

view - 4 - safeAreaLayoutGuide

view1 - (>=40) - view2
复制代码

以上对于表达式的研究提示我们要将所有参与表达式的事情分成三组:layout'ed object (视图), confinement (layout guides 以及被 |-, -|, |prefix 还有 |postfix 包裹起来的东西), 和 constant.

现在我们要将我们的设计变更为:

protocol Operand {
    associatedtype HeadAttribute: SyntaxAttribute
    
    associatedtype TailAttribute: SyntaxAttribute
}

protocol SyntaxAttribute {}

struct SyntaxAttributeLayoutedObject: SyntaxAttribute {}

struct SyntaxAttributeConfinment: SyntaxAttribute {}

struct SyntaxAttributeConstant: SyntaxAttribute {}
复制代码

然后对于像 view1 - 4 - view2 之类的组合,我们可以创建下列表达式类型:

/// 连结 `view - 4`
struct LayoutableToConstantSpacedSyntax<Lhs: Operand, Rhs: Operand>: 
    Operand where
    /// 确认左手边算子的尾部是不是一个 layouted object
    Lhs.TailAttribute == SyntaxAttributeLayoutedObject,
    /// 确认右手边算子的头部是不是一个 constant
    Rhs.HeadAttribute == SyntaxAttributeConstant
{
     typealias HeadAttribute = Lhs.HeadAttribute
     typealias TailAttribute = Lhs.TailAttribute
}

func - <Lhs, Rhs>(lhs: Lhs, rhs: Rhs) -> LayoutableToConstantSpacedSyntax<Lhs, Rhs> { ... }

/// 连结 `(view - 4) - view2`
struct ConstantToLayoutableSpacedSyntax<Lhs: Operand, Rhs: Operand>:
    Operand where
    /// 确认左手边算子的尾部是不是一个 constant
    Lhs.TailAttribute == SyntaxAttributeConstant,
    /// 确认右手边算子的头部是不是一个 layouted object
    Rhs.HeadAttribute == SyntaxAttributeLayoutedObject
{
     typealias HeadAttribute = Lhs.HeadAttribute
     typealias TailAttribute = Lhs.TailAttribute
}

func - <Lhs, Rhs>(lhs: Lhs, rhs: Rhs) -> ConstantToLayoutableSpacedSyntax<Lhs, Rhs> { ... }
复制代码

通过遵从 Operand 协议,一个类型实际上就获得了两个编译时容器,它们的名字分别为:HeadAttributeTailAttribute;其值则是属于 SyntaxAttribute 的类型。通过调用函数 - (上述代码块的任意一个),编译器将检查左手边算子和右手边算子是否和函数返回值(ConstantToLayoutableSpacedSyntaxLayoutableToConstantSpacedSyntax)中的泛型约束一致。如果成功了,我们就可以说状态成功地被转移到另外一个了。

我们可以看到,因为我们在上述类型的体内已经设置了 HeadAttribute = Lhs.HeadAttributeTailAttribute = Lhs.TailAttribute,现在 LhsRhs 的头部和尾部的 attribute 已经从 LhsRhs 上被转移到了这个被新合成的类型上。而值就被储存在其 HeadAttributeTailAttribute 上。

然后我们成功让编译器接受了类似 view1 - 4 - view2, view1 - 10 - view2 - 19 这样的输入……等等!view1 - 10 - view2 - 19??? view1 - 10 - view2 - 19 应该是一个被编译器拒绝的非法输入!

Syntax Boundaries

实际上,我们刚才仅仅只是保证了一个视图紧接着一个数字、一个数字紧接着一个视图,而这和表达式是否以一个视图(或者 layout guide)开始或结束无关。

为了使表达式始终以一个视图,layout guide 或者 |-, -|, |prefix|postfix 开头,我们必须要构建一个帮助我们过滤掉无效输入的逻辑——就像我们之前做的 Lhs.TailAttribute == SyntaxAttributeConstantRhs.HeadAttribute == SyntaxAttributeLayoutedObject 那样。我们可以发现实际上这些表达式可以分为两组:confinementlayout'ed object。为了使表达式始终以这两组表达式中的表达式开头或者结尾,我们必须使用编译时逻辑来实现它。我们用运行时代码写出来就是:

if (lhs.tailAttribute == .isLayoutedObject || lhs.tailAttribute  == .isConfinment) &&
    (rhs.headAttribute == .isLayoutedObject || rhs.headAttribute == .isConfinment)
{ ... }
复制代码

但是这个逻辑不能在 Swift 编译时中被简单实现,而且 Swift 编译时计算的唯一逻辑就是逻辑。由于在 Swift 中我们只能在类型约束中使用逻辑(通过使用 Lhs.TailAttribute == SyntaxAttributeLayoutedObjectRhs.HeadAttribute == SyntaxAttributeConstant 中的 , 符号),我们只能将上述代码块中的 (lhs.tailAttribute == .isLayoutedObject || lhs.tailAttribute == .isConfinment)(rhs.headAttribute == .isLayoutedObject || rhs.headAttribute == .isConfinment) 融合起来存入一个编译时容器的值,然后使用逻辑来连结他们。

实际上,Lhs.TailAttribute == SyntaxAttributeLayoutedObject 或者 Rhs.HeadAttribute == SyntaxAttributeConstant 中的 == 和大多数编程语言中的 == 操作符等效。另外,Swift 编译时计算中也有一个和 >= 等效的操作符: :

考虑下列代码:

protocol One {}
protocol Two: One {}
protocol Three: Two {}

struct Foo<T> where T: Two {}
复制代码

现在 Foo 中的 T 只能是「比 Two 大」的了.

然后我们可以将我们的设计变更为:

protocol Operand {
    associatedtype HeadAttribute: SyntaxAttribute

    associatedtype TailAttribute: SyntaxAttribute

    associatedtype HeadBoundary: SyntaxBoundary

    associatedtype TailBoundary: SyntaxBoundary
}

protocol SyntaxBoundary {}

struct SyntaxBoundaryIsLayoutedObjectOrConfinment: SyntaxBoundary {}

struct SyntaxBoundaryIsConstant: SyntaxBoundary {}
复制代码

这一次我们加入了两个编译时容器:HeadBoundaryTailBoundary,其值是属于 SyntaxBoundary 的类型。对于视图或者 layout guide 对象而言,他们提供了首尾两个 SyntaxBoundaryIsLayoutedObjectOrConfinment 类型的 boundaries。当调用 - 函数时,视图或者 layout guide 的 boundary 信息就会被传入新合成的类型中。

/// 连结 `view - 4`
struct LayoutableToConstantSpacedSyntax<Lhs: Operand, Rhs: Operand>: 
    Operand where
    /// 确认 LhsTailAttributeSyntaxAttributeLayoutedObject
    Lhs.TailAttribute == SyntaxAttributeLayoutedObject,
    /// 确认 RhsHeadAttributeSyntaxAttributeConstant
    Rhs.HeadAttribute == SyntaxAttributeConstant
{
    typealias HeadBoundary = Lhs.HeadBoundary
    typealias TailBoundary = Rhs.TailBoundary
    typealias HeadAttribute = Lhs.HeadAttribute
    typealias TailAttribute = Lhs.TailAttribute
}

func - <Lhs, Rhs>(lhs: Lhs, rhs: Rhs) -> LayoutableToConstantSpacedSyntax<Lhs, Rhs> { ... }
复制代码

现在我们可以修改我们的 withVFL 系列函数的函数签名为:

func withVFL<O: Operand>(V: O) -> [NSLayoutConstraint] where
    O.HeadBoundary == SyntaxBoundaryIsLayoutedObjectOrConfinment,
    O.TailBoundary == SyntaxBoundaryIsLayoutedObjectOrConfinment
{ ... }
复制代码

然后,只有 boundaries 是视图或者 layout guide 的表达式才能被接受了。

Syntax Associativity

但是 syntax boundaries 的概念还是不能帮助编译器停止接受如 view1-| | view2 或者 view2-| - view2 之类的输入。这是因为即使一个表达式的 boundaries 被确保了,你还是不能保证这个表达式是否是 associable (可结合)的。

于是我们要在我们的设计中引入第三对 associatedtype

protocol Operand {
    associatedtype HeadAttribute: SyntaxAttribute

    associatedtype TailAttribute: SyntaxAttribute

    associatedtype HeadBoundary: SyntaxBoundary

    associatedtype TailBoundary: SyntaxBoundary

    associatedtype HeadAssociativity: SyntaxAssociativity

    associatedtype TailAssociativity: SyntaxAssociativity
}

protocol SyntaxAssociativity {}

struct SyntaxAssociativityIsOpen: SyntaxAssociativity {}

struct SyntaxAssociativityIsClosed: SyntaxAssociativity {}

复制代码

对于像 |-, -| 之类的表达式或者一个表达式中的 layout guide,我们就可以在新类型的合成过程中关掉他们的 associativity。

这足够了吗?

是的。实际上,我在这里做了个弊。你也许会惊讶,为什么我可以通过举例快速地发现问题,一起可以对上面这个问题没有犹豫地说「是」。原因是,我已经在纸上枚举完了所有语法树的构型。在纸上计划是成为一个优秀软件工程师的好习惯。

现在语法树设计的核心概念已经非常接近我的生产代码了。你可以在这里查看他们。

生成 NSLayoutConstraint 实例

好了,回来。我们还有东西要来实现。这对我们整体的工作很重要——生成布局约束。

由于我们在 withVFL(V:) 系列函数的参数中所获的的是一个语法树,我们可以简单地构建一个环境来对这个语法树进行求值。

我正在克制自己使用大词,所以我说的是「构建一个环境」。但是禁不住告诉你,我们现在要开始构建一个虚拟机了!

一些语法树的例子
一些语法树的例子

通过观察一颗语法树,我们可以发现每一层语法树都是或不是一个单目操作符节点、双目操作符节点或者算子节点。我们可以将 NSLayoutConstraint 的计算抽象成小碎片,然后让这三种节点产生这些小碎片

听起来很好。但是怎样做这个抽象呢?如何设计那些小碎片呢?

对于有虚拟机设计经验或者编译器构造经验的人来说,他们也许会知道这是一个有关「过程抽象」和「指令集设计」的问题。但是我并不想吓唬到像你这样可能对这方面没有足够知识的读者,于是我之前称呼他们为「将 NSLayoutConstraint 的计算抽象成」「小碎片」。

另一个让我不以「过程抽象」和「指令集设计」来谈论这个问题的理由是「指令集设计」是整个解决方案的最前端:你之后将会得到一个被称作 opcode (operation code 的缩写,我也不知道为什么他们这样缩略这个术语)的东西。但是「指令集设计」会严重影响「过程抽象」的最终形态,而如果在做「指令集设计」之前跳过思考「过程抽象」的问题的话,你也很难揣测出指令集背后的概念。

抽象 NSLayoutConstraint 的初始化过程

由于我们要支持 layout guide,那么老式的 API:

convenience init(
    item view1: Any,
    attribute attr1: NSLayoutConstraint.Attribute,
    relatedBy relation: NSLayoutConstraint.Relation,
    toItem view2: Any?,
    attribute attr2: NSLayoutConstraint.Attribute,
    multiplier: CGFloat,
    constant c: CGFloat
)
复制代码

就变得不可用了。你沒法用这个 API 让 layout guide 工作。是的,我试过。

然后我们也许会想起 layout anchors。

是的,这是可行的。我的生产代码就是利用的 layout anchors。但是为什么 layout anchors 可行?

实际上,我们可以通过检查文档来知道 layout anchors 的基类 NSLayoutAnchor 有一组生成 NSLayoutConstraint 的 API。如果我们可以在确定的步骤内获得这组 API 的所有参数,那么我们就可以为这个计算过程抽象出一个形式化的模型。

我们可以在确定的步骤内获得这组 API 的所有参数吗?

答案显然是「是的」。

语法树求值一瞥

在 Swift 中,语法树的求值是深度优先遍历的。下面这张图就是下面这个代码块中 view1 - bunchOfViews 的遍历顺序。

let bunchOfViews = view2 - view3
view1 | bunchOfViews
复制代码

Swift 语法树遍历
Swift 语法树遍历

但是虽然根节点是整个求值过程中最先被访问的,由于它需要它左手边子节点和右手边子节点的求值过程来完成求值过程,它将在最后一个生成 NSLayoutConstraint 实例。

抽象 NSLayoutConstraint 的计算过程

通过观察上面这个 Swift 语法树求值过程的插图,我们可以知道节点 view1 将于第二位被求值,但是求值结果最后才用得上。所以我们需要一个数据结构可以保存每一个节点的求值结果。你也许想起来了要用栈。是的。我在我的生产代码中就是用的栈。但是你应该知道为什么我们要用栈:一个栈可以将递归结构转换为线性的,这就是我们想要的。你也许已经猜到了我要用栈,但是直觉并不是每次都灵。

有了这个栈,我们就可以将所有初始化一个 NSLayoutConstraint 实例的计算资源放入之中了。

另外,我们也要让栈能够记忆已经被求完值的语法树的首尾节点。

为什么?看看下面这个语法树:

一个复杂的语法树
一个复杂的语法树

这个语法树由以下表达式生成。

let view2_3 = view2 - view3
let view2_4 = view2_3 - view4
view1 | view2_4
复制代码

当我们对位于树的第二层(从根节点开始数)的 - 节点进行求值时,我们必须要选取 view3 这个「内侧」来创建一个 NSLayoutConstraint 实例。实际上,生成 NSLayoutConstraint 实例总是需要选取从被求值节点看起来是「内侧」的节点。但是对于跟节点 | 来说,「内侧」节点就变成了 view1view2。所以我们不得不让栈来记忆被已经求完值的语法树的首尾节点。

关于 "返回值"

是的,我们不得不设计一个机制来让语法树的每一个节点来返回求值结果。

我并不想谈论真实电脑是如何在栈帧间是如何传递返回值的,因为这会根据返回数据的大小不同而不同。在 Swift 世界中,由于所有东西都是安全的,这意味着能够绑定一片内存为其他类型的 API 是非常难用的,以碎片化的节奏来处理数据也不是一个好选择(至少不是编码效率的)。

我们只需要使用一个在求值上下文中的本地变量来保存栈的最后一个弹栈结果,然后生成从这个变量取回数据的指令,然后我们就完成了「返回值」系统的设计。

构建虚拟机

一旦我们完成了过程抽象,指令集的设计就只差临门一脚了。

实际上,我们就是需要让指令做如下事情:

  • 取回视图、layout guide、约束关系、约束常数、约束优先级。

  • 生成要选取那个 layout anchor 的信息。

  • 创建布局约束。

  • 压栈、弹栈。

完成的生产代码在这里

评估

我们已经完成了我们这个编译时确保安全的 VFL 的概念设计。

问题是我们得到了什么?

对于我们的编译时确保安全的 VFL

我们在此获得的优势是表达式的正确性是被保证了的。诸如 withVFL(H: 4 - view) 或者 withVFL(H: view - |- 4 - view) 之类的表达式将被在编译时就被拒绝。

然后,我们已经让 layout guide 和我们的 VFL Swift 实现一起工作了起来。

第三,由于我们是在执行由编译时组织的语法树生成的指令,总体的计算复杂度就是 O(N),这个 N 是语法树生成的指令的数目。但是因为语法树并不是编译时完成构建的,我们必须要在运行时完成语法树的构建。好消息是,在我的生产代码中,语法树的类型都是 struct,这意味着语法树的构建都是在栈内存上而不是堆内存。

事实上,在一整天的优化后,我的生产代码超越了所有已有的替代方案(包括 Cartography 和 SnapKit)。这当然也包含了原版的 VFL。我将会在本文后部分放置一些优化技巧。

对于 VFL

理论上,相对于我们的设计,原版 VFL 在性能上存在一些优势。VFL 字符串实际上在可执行文件(Mach-O 文件)的 data 段中被储存为了 C 字符串。操作系统直接将他们载入内存且在开始使用前不会有任何初始化动作。载入这些 VFL 字符串后,目标平台的 UI 框架就预备对 VFL 字符串进行解析了。由于 VFL 语法十分简单,构建一个时间复杂度是 O(N) 的解析器也很简单。但是我不知道为什么 VFL 是所有帮助开发者构建 Auto Layout 布局约束方案中最慢的。

性能测试

以下结果通过在 iPhone X 上衡量 10k 次布局约束构建测得。

Benchmark 1
Benchmark with 1 View
Benchmark 2
Benchmark with 2 Views
Benchmark 3
Benchmark with 3 Views


深入阅读

Swift 优化

Array 的代价

Swift 中的 Array 会花费很多时间在判断它的内部容器是 Objective-C 还是 Swift 实现的这点上。使用 ContiguousArray 可以让你的代码单单以 Swift 的方式思考。

Collection.map 的代价

Swift 中的 Collection.map 被优化得很好——它每次在添加元素前都会进行预分配,这消除了频繁的分配开销。

Collection.map 的代价
Collection.map 的代价

但是如果你要将数组 map 成多维数组,然后将他们 flatten 成低维数组的话,在一开始就新建一个 Array 然后预分配好所有空间,再传统地调用 Arrayappend(_:) 函数会是一个更好的选择。

不具名类型的代价

不要在写入场合使用不具名类型(tuples)。

Non-Nominal Types 的代价
Non-Nominal Types 的代价

当写入不具名类型时,Swift 需要访问运行时来确保代码安全。这将花费很多时间,你应该使用一个具名的类型,或者说 struct 来代替它。

subscript.modify 函数的代价

在 Swift 中,一个 subscript(self[key] 中的 [key]) 有三种潜在的配对函数。

  • getter

  • setter

  • modify

什么是 modify?

考虑以下代码:

struct StackLevel {
    var value: Int = 0
}

let stack: Array<StackLevel> = [.init()]

// 使用 subscript.setter
stack[0] = StackLevel(value: 13)

// 使用 subscript.modify
stack[0].value = 13
复制代码

subscript.modify 是一种用来修改容器内部元素的某一个成员值的函数。但是它看起来做的比单纯修改值要多。

subscript.modify 的代价
subscript.modify 的代价

我甚至无法理解我的求值树中的 mallocfree 是怎么来的。

我将求值栈从 Array 替换为了自己的实现,并且实现了一个叫 modifyTopLevel(with:) 的函数来修改栈的顶部。

internal class _CTVFLEvaluationStack {
    internal var _buffer: UnsafeMutablePointer<_CTVFLEvaluationStackLevel>

    ...

    internal func modifyTopLevel(with closure: (inout _CTVFLEvaluationStackLevel) -> Void) {
        closure(&_buffer[_count - 1])
    }
}
复制代码

OptionSet 的代价

Swift 中 OptionSet 带来的方便不是免费的.

OptionSet 的代价
OptionSet 的代价

你可以看到 OptionSet 使用了一个非常深的求值树来获得一个可以被手动 bit masking 求得的值。我不知道这个现象是不是存在于 release build 中,但是我现在在生产代码中使用的是手动 bit masking。

Exclusivity Enforcement 的代价

Exclusivity enforcement 也对性能有冲击。在你的求值栈中你可以看见很多 swift_beginAccesswift_endAccess 的调用。如果你对自己的代码有自信,我建议关掉运行时 exclusivity enforcement。在 Build Settings 中搜索 “exclusivity” 可以看到相关选项。

在 Swift 5 下的 release build 中,exclusivity enforcement 是默认开启的.

Exclusivity Enforcement 的代价
Exclusivity Enforcement 的代价

C 的编译时计算

我还在我的一个框架中实现了一种有趣的语法: 通过 metamacros.h 来为 @dynamic property 来添加自动合成器。范例如下:

@ObjCDynamicPropertyGetter(id, WEAK) {
    // 使用 _prop 访问 property 名字
    // 其余和一个 atomic weak Objective-C getter 一样.
}

@ObjCDynamicPropertyGetter(id, COPY) {
    // 使用 _prop 访问 property 名字
    // 其余和一个  atomic copy Objective-C getter 一样.
}

@ObjCDynamicPropertyGetter(id, RETAIN, NONATOMIC) {
    // 使用 _prop 访问 property 名字
    // 其余和一个  nonatomic retain Objective-C getter 一样.
};
复制代码

实现文件在.

对于 C 程序员而言,metamacros.h 是一个非常有用的用来创建宏以减轻难负担的脚手架。


谢谢你阅读完了这么长的一篇文章。我必须要道歉:我在标题撒了谎。这篇文章完全不是「浅谈」Swift 泛型元编程,而是谈论了更多的关于计算的深度内容。但是我想这是作为一个优秀程序员的基础知识。

最后,祝愿 Swift 泛型元编程不要成为 iOS 工程师面试内容的一部分。


原文刊发于本人博客(英文)

本文使用 OpenCC 进行繁简转换

关注下面的标签,发现更多相似文章
评论