在写 Rust 的时候尝试写出易维护的代码,其中大量参考了函数式的思想。

纯函数

理想情况下应该使用纯函数,因为纯函数输出只依赖于输入,不会因为其他外部因素而改变。

fn foo(input: usize) -> usize {
	input * 2
}

在上面的函数中会将输入参数乘以 2 再输出,它的输出是确定且显而易见的,如果输出了 2,则一定只会输出 4,不可能会出现其他输出。这样的纯函数非常好维护。

来看一个复杂一点的函数,它会查询数据库:

fn query(id: i32) -> String {
	let db = DatabaseConnection::new();
	
	let model = query(db);
	model.name
}

上面的函数通过 id 查询数据库离某条数据的 name,它不是一个纯函数,因为它完全依赖了一个外部的数据库。假设它接收了参数 0,查询数据库得到 namename_1,但是在下次接收参数 0,得到的可能就是 name_233 了。因为数据库的数据可能已经发生了变化。

且这样的依赖也可能让环境的配置变得困难。想象一下我想要在测试环境测试 query,但是它里面使用了 DatabaseConnection::new(),我要怎么让它在测试环境下使用测试数据库呢?

依赖注入

一种方式就是将需要的依赖当作入参传递给函数。

fn foo(id: i32, db: DatabaseConnection) -> String {
	...
}

像上面那样,不在函数里去获取函数外的依赖 DatabaseConnection,而是将依赖作为入参传递,这样依赖,函数的执行结果不再受到入参以外的因素影响。如果需要在测试环境中使用测试数据库,也只不过改变传入的 db 参数而已。

Struct 的依赖

上面的例子的函数里有两个入参,如果函数需要的依赖多了全放在入参里就会很难看。我们就可以把所有的依赖抽象成一个 struct。

struct Foo {
	db: DatabaseConnection,
	http: HttpClient,
	...
}

impl Foo {
	pub fn new(db: DatabaseConnection, http: HttpClient) -> Self {
		...
	}
	
	pub fn query(&self, id: i32) -> String {
		...
	}
}

上面的 struct 中就包含了外部依赖,假设是 DatabaseConnectionHttpClient 等十几个吧,我们还贴心提供了 new() 方法用于初始化这个 struct。将依赖放在 struct 中后,调用查询的方法 query 就会变得非常清爽——只需要一个 id 参数。

虽然这样的 query 严格上来说是一个方法而不是函数了,但它和纯函数仍然是类似的。我们先把他看作是一个函数,你会看到方法体内会有 self.dbself.http 之类的写法,看起来好像是引用了这个函数外部的依赖,它的写法会像是:

let foo = Foo::new();
let name = foo.query(0);

它好像隐藏了它的 struct 的细节,但如果我们换一种写法,就会发现它其实还是一个纯函数:

let foo = Foo::new();
let name = Foo::query(&foo, 0);

上面是脱糖的写法,脱糖后能够看到第一种写法是隐藏了一个 self 指针,函数需要的依赖仍然是通过入参的方式传递给 query 函数使用。

这样的方式可以很轻松得得到一个易于维护的对象,它的行为十分好预测,当出现错误时也很好定位到问题。如果需要对 Foo 进行测试,也只不过是在构建 Foo 时候给它一个测试用的依赖。