变异没问题
在我们的编程社区里,“变异”这个词几乎开始带有负面含义。它就像是变异本身就是错的。就好像我们变异了,就不再能写出我们钟爱的函数式代码了。变异真的那么邪恶吗?或者说,有些误用是真的吗?让我们深入探讨一下。
声明
函数式编程通常只使用表达式进行编程,而表达式的计算结果是一个值,因此它没有副作用。但是,如果函数局部使用了命令式语句,会有什么问题呢?
// expression based
const userName(u: User) => u.secured ? "No access" : u.name;
// statement based
function userName(u: User) {
if (u.secured) {
return "No access";
} else {
return u.name;
}
}
好吧,虽然我在第二种方法中用到了语句,但可能大多数人都没觉得这两种方法有什么问题。之后我们就可以在函数式编程中使用语句了。希望我们在这一点上达成了一致。
注意,我故意使用箭头函数作为表达式,因为箭头函数是一个表达式,而函数声明是一个语句。
局部突变
// declarative / expression based
const removeInactive (users: User[]) =>
users.filter(user => user.active)
// imperative / statement based
function removeInactive (users: User[]) {
let newUsers = []
for (u in users) {
if (u.active) {
newUsers.push(u)
}
}
return newUsers;
}
现在代码更有争议了。声明式代码很短,没有变量,对于有函数式编程基础的人来说也更易读。命令式代码更长,有变量,并且支持局部变量。
如果有人问我哪种代码更适合你,我肯定会选择第一种。但是,如果有人写了第二种,这会给我们的代码库带来什么问题吗?
从直升机视角来看函数的行为方式,两者都是
- 参照透明性(相同的输入产生相同的输出)
- 没有副作用
从接口的角度来看,这两个函数似乎是等价的,它们都是纯数学函数。如果某个开发人员以命令式的方式编写这样的函数,并将其放入某个库中,那么没有人会注意到,甚至没有人会在意。这就是问题所在。这个函数内部的内容是——实现细节。
注意,如果我们考虑局部复杂性的比较,那么就会有一个明显的赢家。
减少它
很多人说 Reduce 可能被过度使用,而且很多时候我们用 Reduce 写的代码只会变得过于复杂。以我的经验来看,我从未觉得 Reduce 有什么问题,但如果我们把它当成锤子一样用,它就真的成了问题。
// reduce version - declarative
const intoCSV = (users: User[]) =>
users.reduce((acc, user) => {
const prefix = acc.length === 0 ? "" : ",";
return acc + prefix + user.name;
}
, "");
// for..of version - imperative
function intoCSV (users: User[]) {
let csv = "";
for (const user of users) {
const prefix = csv.length === 0 ? "" : ",";
csv = csv + prefix + user.name;
}
return csv;
}
就输入到输出而言,两个版本的intoCSV函数都相同。尽管第二个函数内部包含语句和变量,但它们仍然是纯函数。但可读性方面不如前面的例子那么明显。reduce 版本也好不到哪里去。我想说,这里没有明显的赢家。
复制还是不复制
// reduce version - declarative
const intoUsersById = (users: User[]) =>
users.reduce((acc, user) => ({...acc, [user.id]: user })
, {} as { [k: number]: User });
// for..of version - imperative
function intoUsersById (users: User[]) {
let byId: { [k: number]: User } = {};
for (const user of users) {
byId[user.id] = user;
}
return byId;
}
下一个示例展示了声明式版本的另一个问题。过度使用结构体复制也很常见。示例中,我们在每次“迭代”过程中都会对最终对象进行浅拷贝。这会对性能产生实际影响。当然,我们不必过于担心,但如果我们的集合是由 node.js/deno 处理的,我们就应该担心了。关于这方面的更多思考,请参阅我之前的文章《函数式 JS 中的数据变异》。
不过,你不必担心在这里进行修改。它是本地变量,而不是共享变量,在你完成之前,没有人可以使用它。在这种情况下,修改是允许的,而且是可取的。
请注意,我的观点并非反对 Reduce。我喜欢并使用 Reduce,但我们需要理解其中的利弊。当然,我们也可以进行修改并使用 Reduce。
为什么人们说突变是错误的?
首先,人们说了很多,但并非所有都是正确的😉。其次,我们目前对函数式编程(FP)的炒作如此强烈,以至于有些人会深入到范式的阴暗角落,甚至在没有论据证明的地方宣称函数式编程至上。我也是函数式编程的粉丝,但我也遵循常识。
是的,如果我们使用基于表达式的语言,如 Haskell、Elm、PureScript,那么我们只编写表达式和纯函数,但这正是这些语言的设计方式。
在 TypeScript、JavaScript、Java、C# 等多范式语言中,我们应该理解语言并非为某些概念而生,也应该理解其中存在语句和突变。如果我们知道何时可以安全地使用这些语句和突变,那么一切都应该没问题。
但什么时候突变真的是错误的呢?
任何不属于函数本身的东西都不应该被修改。我所说的“属于”指的是函数体内部创建的东西。换句话说,我们可以修改局部变量,但应该避免修改外部状态和输入参数。如果我们遵循这条规则,那么修改就不会对我们造成影响。
这个概念众所周知,Rust 语言的核心概念也源于此。看一下借用。
概括
命令式核心,函数式外壳……等等?没错,常见的架构模式是“函数式核心,命令式外壳”,它的核心在于将副作用添加到边界。我在这里开启了一个关于如何构建这种命令式外壳的迷你系列。但在本文中,我们所做的恰恰相反,我们使用微突变来在纯函数内部生成一些数据。不要害怕这样做,只要函数外部实现引用透明,一切就都好。
如果您喜欢这篇文章并希望阅读我的更多文章,请在dev.to和twitter上关注我。
鏂囩珷鏉ユ簮锛�https://dev.to/macsikora/mutation-is-ok-3e00