Zepo's blog


  • 首页

  • 归档

Objective-C单向数据流方案

发表于 2017-09-30 |

背景

当我们在写Objective-C代码时,会习惯性地把model对象的属性定义为nonatomic。如果该属性是被多线程访问的,那么这样做是有可能crash的。我们可以简单地模拟一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Conversation.h
@interface Conversation : NSObject
@property (nonatomic, copy) NSArray *messages;
@end
// xxx.m
- (void)methodA
{
Conversation *conversation = [[Conversation alloc] init];
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (1) {
conversation.messages = [[NSArray alloc] initWithObjects:@1, @2, @3, nil];
}
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (1) {
NSLog(@"%@", conversation.messages);
}
});
}

调用上面的methodA方法,程序在运行时会抛出EXC_BAD_ACCESS异常。

如果把属性定义为atomic,我们能避免上面的crash,但仍然存在其他多线程带来的问题。比如竞争条件(race condition)问题,数据一致性问题等等。另外,由于属性是可变的,我们可以在程序的任意地方修改该属性,如果该属性作为某个页面的展示数据,那么我们需要在所有修改的地方发出通知以刷新页面。如果该属性对应的是UITableView的cells,修改该属性而没有通知到UITableView做reloadData的话同样会导致crash。

随着app的不断发展而变得复杂,修改同一个属性的地方会不断增多,所有这些修改和通知会变得很难维护。一旦出现问题,我们也很难找到修改数据的源头,调试这类bug的成本变得很高。

Flux & Redux

对于上面的问题,在js界已经有很成熟的解决方案:Flux和Redux。这两者通过各自的编程规范,来避免上面的数据可变带来的问题。我们以Redux为例,来做具体的分析。下面的分析主要是对Redux官方文档的复述,会有些繁琐,如果你对Redux已经非常熟悉,可跳过该部分。Redux主要有以下几个部分:



Store

整个app只有一个store,且app的所有数据以dictionary的形式存在该store里。以Redux官网的todo app为例,整个app的数据主要分为两块,todo列表todos和过滤器visibilityFilter:

1
2
3
4
5
6
7
8
9
10
{
todos: [{
text: 'Eat food',
completed: true
}, {
text: 'Exercise',
completed: false
}],
visibilityFilter: 'SHOW_COMPLETED'
}

Action

对于store里的数据,外部是不能直接修改的。所有数据的修改都必须通过store提供的dispatch接口,传进一个action,在store内部进行。Action是对修改操作的描述:

1
2
3
{ type: 'ADD_TODO', text: 'Go to swimming pool' }
{ type: 'TOGGLE_TODO', index: 1 }
{ type: 'SET_VISIBILITY_FILTER', filter: 'SHOW_ALL' }

用action来描述所有修改操作有很多好处,比如我们可以很方便地记录所有修改以便调试。如果记录了初始状态和所有actions我们也可以很方便地实现回放,撤消(undo)等功能。

Reducer

Store在接收到action后,会通过reducer来修改内部的状态。Reducer只是一些普通的纯函数,输入初始状态和action,输出修改后的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function visibilityFilter(state = 'SHOW_ALL', action) {
if (action.type === 'SET_VISIBILITY_FILTER') {
return action.filter
} else {
return state
}
}
function todos(state = [], action) {
switch (action.type) {
case 'ADD_TODO':
return state.concat([{ text: action.text, completed: false }])
case 'TOGGLE_TODO':
return state.map(
(todo, index) =>
action.index === index
? { text: todo.text, completed: !todo.completed }
: todo
)
default:
return state
}
}
function todoApp(state = {}, action) {
return {
todos: todos(state.todos, action),
visibilityFilter: visibilityFilter(state.visibilityFilter, action)
}
}

注意reducer在执行时不会直接修改原数据,而是重新生成整棵状态树,model数据是不可变的。

我们可以看到,在Redux里数据的流向是单向的。数据只能从store流向view,而不能从view流向store。当在view上进行操作需要修改数据时,我们要用action来描述操作,然后把action传进store里,在store内部修改数据。这样,我们就把所有的修改都收拢到了store这一层。同时,我们也只需要在store这一层发出通知来刷新view,所有的通知也被收拢到了一个地方。这样就解决了我们前面提到的修改和通知很难维护的问题。

Objective-C的不适应性

我们可以把Redux这套方案直接应用到Objective-C上,但这样做存在几个问题:

Action的定义

JavaScript是弱类型语言,把action定义为dictionary是很自然的事。但在Objective-C里,如果我们把action定义为NSDictionary,就失去了强类型语言带来的好处。我们也可以为每一个action定义一个相应的类,但这样又会使开发变得很繁琐。通常,客户端app执行一个action操作是比较复杂的,涉及数据库操作和网络请求,大多数时候我们需要再抽出一个方法来执行action。这样同时定义action和定义执行action的方法会使开发变得很重复。

Store的存储

通常客户端app的数据是比较多,而且我们需要在app的多次启动间保存数据。因此,对于大多数客户端app,部分数据是存在磁盘的,我们不可能把所有数据以dictionary的形式存在内存。当数据存在磁盘时,我们也无法用类似reducer的纯函数来修改store的状态。

Reflow解决方案

Reflow参照了Redux的架构和规范,实现了Objective-C语言的单向数据流方案,同时解决了语言的不适应性问题。下面我们来具体的分析一下Reflow:

Store

与Redux类似,在Reflow里我们要求所有的数据都存在store这一层,且所有的修改和通知也收拢到store这一层。但在Reflow里,store是抽象的概念,store里的数据可以存在磁盘,也可以存在内存,也可以是两者的混合。Store这一层通过对外暴露getters接口以拿数据,暴露actions接口以修改数据。随着app的不断发展而变得复杂,我们可以把store划分成多个模块,每个模块都继承于RFStore:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@interface TodoStore : RFStore
#pragma mark - Getters
- (NSArray *)visibleTodos;
- (VisibilityFilter)visibilityFilter;
#pragma mark - Actions
- (void)actionAddTodo:(NSString *)text;
- (void)actionToggleTodo:(NSInteger)todoId;
- (void)actionSetVisibilityFilter:(VisibilityFilter)filter;
@end

Action

Action是定义在store上的普通方法,action的方法名都以action开头。Reflow会对所有以action开头的方法做特殊处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@implementation TodoStore
...
#pragma mark - Actions
- (void)actionAddTodo:(NSString *)text {
Todo *todo = ...
self.todos = [self.todos arrayByAddingObject:todo];
}
- (void)actionToggleTodo:(NSInteger)todoId {
self.todos = [self.todos map:^id(Todo *value) {
if (value.todoId == todoId) {
Todo *todo = ...
return todo;
}
return value;
}];
}
- (void)actionSetVisibilityFilter:(VisibilityFilter)filter {
self.filter = filter;
}
@end

在action方法里,我们只需做数据修改的任务,而不用去发通知以刷新UI。并且,在Reflow里,我们建议所有的数据修改都要生成新的model对象并替换,而不是直接修改原model对象的属性。

Subscriptions

继承RFStore后,所有store模块都有subscribe接口。我们可以通过该接口订阅发生在该store模块上的所有action操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@implementation TodoTableViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.todoStore = [[TodoStore alloc] init];
self.todos = [self.todoStore visibleTodos];
self.filterButton.title = [self stringFromVisibilityFilter:[self.todoStore visibilityFilter]];
self.subscription = [self.todoStore subscribe:^(RFAction *action) {
if (action.selector == @selector(actionSetVisibilityFilter:)) {
self.filterButton.title = [self stringFromVisibilityFilter:[self.todoStore visibilityFilter]];
}
self.todos = [self.todoStore visibleTodos];
[self.tableView reloadData];
}];
}
...
@end

每当store模块上的action方法被调用后,该store模块会拼装一个RFAction对象,作为参数调用所有订阅的block。RFAction对象与Redux的action类似,包含了描述一个操作所需的信息:

1
2
3
4
5
6
7
@interface RFAction : NSObject
@property (nonatomic, readonly) id object;
@property (nonatomic, readonly) SEL selector;
@property (nonatomic, readonly) NSArray *arguments;
@end

我们也可以通过下面的方法订阅所有store模块的所有action,这样我们就可以记录app的所有修改以便调试,也可以很容易地实现回放操作,撤消操作等:

1
2
3
[RFStore subscribeToAllStores:^(RFAction *action) {
...
}];

上面的完整的例子可以参考Github上的Example。

总结

Reflow这个库相对比较小,代码量也很少。对于Reflow来说,更重要的是它的架构设计和规范:

  • model对象不可变
  • 整个app的数据存于store层
  • 更新和通知也收拢于store层

Reflow的设计参考了很多优秀的开源框架和文章,这里把它们列出来以供参考:

  • Flux
  • Redux
  • Vue
  • Building and managing iOS model objects with Remodel

函数式编程通俗简介

发表于 2017-08-18 |

函数式编程(functional programming)是一种与面向对象编程和过程式编程并列的编程范式。函数式编程来源于数学学科,因此有很多的数学理论方面的约束,但同时也得益于数学性框架带来的种种好处。很多初学者在接触函数式编程时都会感觉不适应,认为函数式编程很抽象,学习成本很高。本文试图通过一种通俗易懂的方式,来向大家介绍函数式编程是什么,以及更重要的,我们为什么要用函数式编程。

从初中数学入手

函数式编程来源于一门叫范畴论(category theory)的数学分支。范畴论这门学科同样也是很抽象的,因此,我们不直接从范畴论入手。但范畴论作为一门数学学科,它跟其它数学学科一样使用普遍的数学定义,遵从一般的数学定理。从这些我们已经熟知的数学定义和定理入手,会使学习函数式编程更简单,理解更深刻。

我们先来回顾一下初中数学函数的定义(下面的定义来自于维基百科):

函数在数学中为两集合间的一种对应关系:输入值集合中的每项元素皆能对应唯一一项输出值集合中的元素。

关于这个数学函数的定义有两个特点要强调一下:

  • 函数的输入只有一个元素,或者说函数的输入参数只有一个
  • 对于相同的输入,函数的输出总是固定不变的,不管函数执行几次

在函数式编程里,我们使用的函数与数学函数一样,同样满足以上两个特点。你可能会认为第一个特点的限制太严格了,通常在编程里的函数都有好几个参数,如果限制一个参数我们的函数会很不灵活。我们将在后面介绍如何解决这个问题,下面我们先来看第二个特点。

纯函数

我们把满足上面第二个特点的函数称作纯函数(pure function):

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。

这里的副作用(side effect)包含但不限于以下几种情况:

  • 访问磁盘文件,包括数据库
  • 发起网络请求
  • 使用全局变量
  • 修改任意变量的值,包括输入参数
  • 刷新界面或打印日志

可以说,任何与函数外部的东西打交道都是不允许的,外部的信息只能通过参数传进函数,同样的,函数只能通过输出将信息传出到外部。

你可能会觉得很不可思议,如果连读写磁盘,发起网络请求,刷新界面这些都不可以,那么这个程序还有什么意义。实际上,函数式编程并不是不允许这些,而是强调要把这些副作用隔离开,我们将在后面简单提及用什么方法来解决。

使用纯函数带来很多好外:

  • 可缓存性:对于同样的输入,输出是固定的,因此我们可以把输出结果缓存起来,避免重复计算。
  • 可移植性:函数不依赖任何外部的东西,跟外部没有任何耦合。
  • 可测试性:函数的运行结果不依赖于任何外部环境,完全由输入参数决定。
  • 可并发性:函数不访问任何公共变量或公共资源,没有资源竞争的问题。

复合函数

我们来复习第二个数学定义,复合函数(同样来自维基百科):

给定两个函数f : X → Y和g : Y → Z,其中f的陪域等于g的定义域(称为f、g可复合),则其复合函数,记为g ∘ f,以X为定义域,Z为陪域,并将任意x∈X映射为g(f(x))。有时也省略复合记号“∘”,直接写作g f。

在函数式编程的世界里,构建程序其实就是在合成函数。通过把一些功能单一的可复用的纯函数合成复杂的函数,以此完成复杂的任务。因此,我们需要一个方法来合成函数(以Javascript代码为例):

1
2
3
4
5
var compose = function(f, g) {
return function(x) {
return f(g(x));
};
};

函数的复合满足结合律:若f、g可复合,g、h可复合,则有:

h ∘ (g ∘ f) = (h ∘ g) ∘ f

即:

1
2
3
compose(f, compose(g, h));
// equal
compose(compose(f, g), h);

由于上述两种组合方式产生的函数是一样的,为了使用更方便简洁,我们有必要把compose方法扩展一下,以使它接受任意多的函数作为参数:

1
compose(f, g, h);

柯里化

上面我们遗留了一个问题,函数式编程里的函数接受的参数只能有一个,而我们实际项目中已经存在很多有用的函数,它们接受多个参数。我们如何在函数式编程里复用这些多个参数的函数呢?

这里要用到一个叫柯里化(currying)的概念。不要被这个名字吓到,其实柯里化的原理很简单:只传递给函数一部分参数,并生成一个新的函数去处理剩下的参数。而且有很多库已经实现了curry函数:

1
2
3
4
5
6
7
8
9
10
11
var curry = require('lodash/curry');
var match = curry(function(what, str) {
return str.match(what);
});
match(/\s+/g, 'hello world');
// [ ' ' ]
match(/\s+/g)('hello world');
// [ ' ' ]

声明式编程方式

是时候看一个实际的例子。假设下面的prop,split函数是经过柯里化的(很多库已经提供这样的函数),我们可以看到,在合成isFirstNameFourLetter函数时,我们就像是在拼接管道(pipe)一样把一个个的函数拼接起来。而当我们给予isFirstNameFourLetter参数数据并调用它时,数据就会依次流过管道的每个结点并转换成我们想要的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var head = function(x) {
return x[0];
};
var strLength = function(x) {
return x.length;
};
var isFourLetter = function(x) {
return x === 4;
};
var isFirstNameFourLetter = compose(isFourLetter, strLength, head, split(' '), prop('name'));
isFirstNameFourLetter({
name: 'Jon Snow'
});
// false

函数式编程就是这样的声明式(declarative)的编程方式。在函数式编程里,我们大多数时候是在拼接管道,希望数据流过管道后转变成我们想要的结果。与之相对应的,是命令式(imperative)的编程方式,在命令式的编程方式里,我们的思维是紧跟着程序指令的执行顺序的,我们在告诉计算机,先执行A指令,再执行B指令。

如果你用过SQL,或者是UNIX命令的管道,你可能已经感受到了声明式编程的优势。

Functor

到目前为止,我们的函数式编程有很多任务还是完成不了,比如下面这些:

  • 条件分支
  • 异常处理
  • 读写磁盘
  • 网络请求
  • 异步任务

我们来看一个最简单的例子,假如我们在调用上面的isFirstNameFourLetter函数时,传进的参数不包含name属性,运行该函数会怎样?你可以试一下,在执行prop('name')时,会返回undefine,执行split(' ')时会抛异常。

我们的纯函数跟我们的管道只能有一个输入和一个输出,我们怎么处理这种异常的分支呢?为此,我们引进一个容器类Maybe,来作为流经我们管道的数据类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var Maybe = function(x) {
this.__value = x;
};
Maybe.of = function(x) {
return new Maybe(x);
};
Maybe.prototype.isNothing = function() {
return (this.__value === null || this.__value === undefined);
};
Maybe.prototype.map = function(f) {
return this.isNothing() ? Maybe.of(null) : Maybe.of(f(this.__value));
};

我们对Maybe内的数据的所有操作,都要通过map方法把纯函数传进Maybe容器里。而map方法的返回,依然是Maybe实例。我们可以像下面这样直接拼接管道并运行:

1
2
3
4
5
6
7
8
9
Maybe.of({
name: 'Jon Snow'
}).map(prop('name')).map(split(' ')).map(head).map(strLength).map(isFourLetter);
//=> Maybe(false)
Maybe.of({
nickname: 'Jon Snow'
}).map(prop('name')).map(split(' ')).map(head).map(strLength).map(isFourLetter);
//=> Maybe(null)

像Maybe这样的容器类,我们称之为functor:

functor 是实现了 map 函数并遵守一些特定规则的容器类型。

学习函数式编程,很多时候就是在学习各种各样的functor:

  • Maybe:外理null,undefined等边界情况
  • Either:处理异常错误
  • IO:处理磁盘读写
  • Task:处理异步任务
  • Stream:处理事件流

要把这些functor讲清楚,我们同时需要了解monad概念,由于本文的篇幅原因,这里不作详述。但我相信,如果你已经找到函数式编程的感觉,学习这些functor并不是一件难事。

范畴论

接下来,我们来了解一下函数式编程的来源,范畴论(category theory)。范畴论是一门很抽象的数学分支,范畴论希望通过统一的形式来处理其它多门数学分支,如集合论(set theory),逻辑学(logic)等等。

在范畴论里,范畴(category)包含以下几个部分:

  • 对象集:一个例子是类型String,Boolean,Number,Object等的所有可能取值的集合。
  • 态射集:态射即纯函数,或者说是对象之间的有向边,一个对象通过态射可以映射到同一范畴里的另一个对象。
  • 态射合成的概念:即函数合成的概念。
  • identity态射:一个特殊的态射,任何对象经过该态射总是映射回自己。

范畴论里的另一个重要的概念就是functor。functor把一个范畴映射到另一个范畴,且保持了原范畴的网络结构。



如上图所示,原范畴C里的对象a,经过functor F映射后变成范畴D里的对象Fa(例如Maybe.of(a)),而原范畴里的任一态射f,经过functor F映射后变成范畴D里的新态射map(f)。

范畴论之所以能用统一的形式处理其它数学分支,在于范畴里的对象集可以是许多抽象的东西。比如用范畴论处理集合论时,对象可以是集合,而态射是集合间的函数。范畴里的对象甚至可以是另一个范畴,这也是范畴论抽象的原因。

最后

至此,我们已经看到了函数式编程带来的种种好处。比如纯函数的可缓存性,可移植性,可测试性和可并发性。再比如声明式编程方式使编程站在一个更高的抽象层面,而不必去关心指令级别的细节。除此之外,函数式编程还得益于范畴论的许多数学定理。我们来看一个简单的例子:

1
compose(map(f), map(g)) === map(compose(f, g));

从这个定理我们知道,一个对象经过复合函数compose(f, g)的转换再映射到另一个范畴等价于先映射到另一个范畴再经过复合函数compose(map(f), map(g))的转换。为此,我们可以找出其中较优的路径(性能更高)来实现同一个需求。这种优化可以在底层实现而做到对开发者透明。就好比我们的数据库引擎升级了,但我们的SQL不用做任何改动。

这只是范畴论众多有用的定理中的一个。本文限于篇幅,不能一一缀述,有兴趣的读者可以自己上网查阅。

Zepo

Zepo

2 日志
MLeaksFinder GYDataCenter Reflow
© 2017 Zepo
由 Hexo 强力驱动
|
主题 — NexT.Mist