[译]组合 Composition 究竟是什么?

date
Jun 9, 2021
slug
whatthefuckis-composition
status
Published
tags
Design Pattern
Functional Programming
summary
type
Post
💡
原文:https://whatthefuck.is/composition
广义地说,组合就是把两个或者更多不同的事物放在一起,得到相同「种类」的事物(即输入的结合),将其作为一个输出的结果。
具体的含义可能取决于上下文,所以我们将看一下在 JavaScript 开发中出现的几个例子。

数学中的组合 Composition in Math

虽然数学与前端开发有些无关,但回顾一下数学定义还是很有用的——哪怕只是为了说明这个术语的起源。
假定我们有两个函数,一个函数是 ,它将其参数加倍,另一个函数是 ,它在其参数上增加10。
如果我们把它们放在一起,使一个函数的输出被传递给另一个函数,我们得到了 。这就是一个组合的例子——我们从另外两个函数中「组合」出了这个函数。这就是这个术语的含义。
请注意两个函数的组合是如何给我们创造了另一个函数。它并没有给我们带来完全不同的东西,因此我们可以不断地将其合成多次。
组合是一个广泛的术语,但我们只在「把不同事物组合在一起的结果与原先事物是同一「种类」时」这样的场景使用它——不管它是一个函数、一个组件,等等。

函数组合 Function Composition

组合经常出现在函数式编程的上下文中。在那里,它指的是与数学中相同的概念,但是用代码表示。
比方说,我们有这样的代码:
let date = getDate();
let text = formatDate(date);
let label = createLabel(text);
showLabel(label)
 
这段代码存在一些重复的地方。也可以描述成一种节奏,如果你愿意的话。我们取一个东西,把他转换为其他东西,接着把这个东西再转换成其他东西,如此反复。
但我们能不能更进一步,去掉重复的部分,只留下步骤?
let steps = [
  getDate,
  formatDate,
  createLabel,
  showLabel
];
 
可能有人会说这段代码更加干净。
让我们写一个函数,我们称之为 runSteps ,逐一执行每一个步骤。
function runSteps(steps) {
  let result;
  for (let i = 0; i < steps.length; i++) {
    let step = steps[i];
    // Apply next step in the chain
    result = step(result);
  }
  return result;
}
 
有了这个函数,我们原来的代码就变成了:
runSteps([
  getDate,
  formatDate,
  createLabel,
  showLabel
]);
 
现在,让我们假设我们想执行所有这些步骤,但要在程序的不同地方和不同时间进行。我们可以写一个函数,为我们做这件事:
function showDateLabel() {
  runSteps([
    getDate,
    formatDate,
    createLabel,
    showLabel
  ]);
}
// 我们可以随时调用它!
showDateLabel();
showDateLabel();
 
或者我们可以有一个函数——我们叫它pipe ——来生成我们的函数:
let showDateLabel = pipe(
  getDate,
  formatDate。
  createLabel,
  showLabel
);

// 我们可以随时调用它!
showDateLabel();
showDateLabel();
 
这段代码做了完全相同的事情,但我们不必明确地实现showDateLabel(它只是调用runSteps)。我们把它藏在了pipe里面。
function pipe(...steps) {
  // 返回一个可以为我做这件事的函数
  return function runSteps() {
    let result;
    for (let i = 0; i < steps.length; i++) {
      let step = steps[i];
      result = step(result);
    }
    return result;
  }
}
 
这个可复用的函数让我们重写我们的代码,这样我们就不用手动地一个一个地调用函数,而只需指定步骤。我们称它为管道 pipe ,因为它将前面每个函数的输出「管道化」到下一个函数中。
回顾一下我们的原始代码:
let date = getDate();
let text = formatDate(date);
let label = createLabel(text);
showLabel(label)
 
而这是使用 pipe 后的样子:
let showDateLabel = pipe(
  getDate,
  formatDate,
  createLabel,
  showLabel
);
showDateLabel();
如果你不赞成用函数组合来表达一切,你可能会想——这有什么意义?为什么我们要经历所有这些步骤?第一个例子不是更有可读性吗?你是唯一没有「明白」的人吗?

函数式尤里卡 Functional Eureka

第一次理解 pipe 和函数组合是一个闪光时刻💡。我们不必手动调用我们的函数——相反,我们可以将我们的函数传递给另一个函数,而这个函数会返回给我们一个数来调用我们(原先)的函数。
多么「美丽」!
这里绝对有一个我们不能忽视的深刻见解。我们通过将程序本身的结构——一连串的步骤——变成了代码可以操纵的东西,从而提高了抽象的层次。例如我们可以「教」 pipe 用一些日志能力来包装每一个步骤,或者异步地运行每一个步骤。这是一个强大的技术,值得我们去理解学习。
这种编程风格也可能是工作中的噩梦。我们将函数调用的实际动作「外包」给像 pipe 这样的助手。因此我们再也不能清楚地看到每一段数据是如何进出我们的函数的,因为他们都发生在 pipe 内部。我们增加了一些间接性的代码——(导致了)我们的代码更加灵活,但也不那么直接。增加了太多层次,我们的脑袋就会爆炸💥。
虽然这种编程风格可以大获成功(特别是在强类型语言中,这种语言强制要求那哪些东西可以「适应」其他东西),但它被狂热的程序员们用得有点过头了,他们编写机制的单行线代码,并把控制流隐藏在「优雅」的助手(这里指 pipe 函数),从而获得了多巴胺的刺激。我也是这么做的。

不过,函数组合还是很好的

话虽如此,函数组合的基本思想还是很重要。本质上,它意味着当我们有doX(doY(doZ(thing))) 时,我们可以首先对doXdoYdoZ进行组合,然后将得到的函数进行执行。
在上面这种琐碎的情况下,直接使用它带来的麻烦比它带来的价值更多。但如果问题根据挑战性,它可能会变得更有用。
也许,我们希望每一步骤都能被缓存memoized
也许,每个步骤都是异步发生的,控制流更加复杂。在某些情况下,我们希望在每一步之前或之后发生一些事情,而不需要到处重复这种脆弱的逻辑。
也许,这些步骤本身需要由我们的程序以不同的方式「解释」,因此我们希望将他们的顺序与他们的执行方式分开。
如果我们能记住函数组合的优势,它有时能够激发出有趣的解决方案。这并不意味着每当我们想把两个函数放在一起时都要拿出 pipe 函数。我们不需要向计算机证明我们很聪明,学习了组合的相关课程。通常,普通的函数调用足够了。

组件组合 Component Composition

另一个我们可能听到「组合」这个词的上下文与声明式 UI 编程有关。我们将以 React 组件为例。
React 组件渲染其他组件,从<App><Button> :
function App() {
  return <Screen />;
}
function Screen() {
  return <Form />;
}
function Form() {
  return <Button />;
}
function Button() {
  return <button>Hey there.</button>;
}
这也被称为「组合」,因为我们将事物(组件)放入了其他事物(组件)中,并且他们彼此配合(「组合」)得很好。
 
组合的一个有趣的变体是当一个组件有「插槽 slots」的时候:
function Layout({ sidebar, content }) {
  return (
    <div>
      <div className="sidebar">{sidebar}</div>
      <div className="content">{content}</div>
    </div>
  )
}
 
然后我们可以从不同的父元素中填充这些插槽:
function HomePage() {
  return (
    <Layout
      sidebar={<HomeSidebar />}
      content={<HomeContent />}
    >
  )
}
function AboutPage() {
  return (
    <Layout
      sidebar={<AboutSidebar />}
      content={<AboutContent />}
    >
  )
}
 
请注意,这些「插槽」并不是 React 的特殊功能。它们是一种能让我们像传递其他任何数据一样传递 UI 片段的能力。
这也被称为「组合」,因为我们用不同的子组件对 Layout 进行组合(填充)。把某个东西放在其他东西里面。

组合与继承 Composition vs Inheritance

人们有时会把「组合」与「继承」相比较。这与函数关系不大(我们一直在讨论这个问题),而更多的与对象和类有关——也就是说,与传统的面向对象程序设计有关。
尤其,如果你用类来表达你的代码,那么通过扩展(继承)其他类来复用行为是很诱人的。然而,这使得后续调整逻辑变得有些困难。例如,你可能向同样重用另一个类中的行为,但你不能 extend 超过一个以上的的基类。
有时,人们会说,继承将你「锁定」在你的初始设计中,因为以后改变类的层次结构的成本太高。当人们提出组合是继承的替代方案时,他们的意思是,你可以将该类的一个实例保留成一个字段,而不是扩展类。然后你可以在必要的时候「委派」给哪个实例,但是你也可以自由地做一些不同的事情。
总的来说,业界已经很大程度上远离了将 UI 组件建模为深度继承层次结构的做法,这种做法在2000年代较为常见。
这并不意味着继承总是「坏的」。但这是一个非常生硬的工具,应该适度地使用它。特别是,继承层次较深往往会产生浅层继承不会导致的问题。
现代的前端代码库很少对 UI 使用继承,因为现在所有流行的 UI 库都具有强大的组合内置支持。例如,在 React 中,你不需要去扩展一个 Button,而是在一个父组件中渲染一个<Button> 。即使是拥抱类的 JavaScript UI 库,通常也不使用继承作为复用渲染代码的方式。而这可能是最好的做法。

总结

总而言之,当我们把两个事物组成一个形状相似的第三种事物时,我们就说我们把它们组合了。这个词有数学上的含义,而且它与函数式编程中的含义很接近。但我们离纯函数式编程越远,这个术语就越不正式,越口语化。
函数组合是一个强大的概念,但它提高了抽象的层次,使你的代码变得不那么直接。如果你写代码的风格是在调用函数之前以某种方式组合函数,而你的团队中还有其他成员,请确保你能从这种方法中获得具体的优势。组合的真正优势不是「更干净」或「更好」,而且「漂亮」且间接的代码是有代价的。
 

© Sytone 2021