kayson

你可能不需要 TypeScript(或静态类型)

kayson · 2016-12-20翻译 · 1406阅读 原文链接

Krispy Kreme — Scott Ableman (CC BY-NC-ND 2.0)

自从 Angular 2 项目决定采用 TypeScript,并用它写了所有的文档示例,TypeScript 变得非常受欢迎。但它是否确实值得一试?

在我们深入讨论之前,我有言在先:我是静态类型工具粉丝,并且 TypeScript 是目前我在 JavaScript 社区最喜欢的静态类型系统。

我有使用静态类型语言的背景,包括 C/C++ 和 Java。一开始很难适应 JavaScript 的动态类型,但是一旦我习惯了它们,就感觉像是从长长的、黑暗的隧道回到光明。有很多理由去喜欢静态类型,动态类型也一样。

我不是技术栈和工具的虔诚教徒。我很实际。我是做技术咨询的,最近有很多开发团队在用 Angular 2 和 TypeScript。我如果要给他们建议,我最好了解我在说什么。

我强烈建议,对新的和不同的技术栈和工具要保持开放的心态。相信我,你会在职业生涯中学到很多。

做一名开发者就意味着你选择了一辈子都要学习新东西。现在跟它和解是个好主意。那并不是说要学习所有的新东西,而是愿意接受新事物,如果你需要为了工作去学习它,或者只是为了发现的乐趣的话。

做一名软件开发者最棒的事情之一,就是发现的乐趣。

几个月前,我决定尝试 Angular 2 和 TypeScript。我全身心投入了几个月,在一个真实的开发团队里参与开发了一个真实的产品 app。

我的大体总结是这样的:

  1. Angular 2 拥有许多 React 没有的东西,但是那些额外的东西并没有让我变得更高效,并且其中的大部分在第三方模块里都有。

  2. TypeScript 做得有点过头了。它没有减少 bug,也没有提高我们的生产力。

关于这个大胆的总结,你可以在 “Angular 2 与 React: 最后的狂欢” 参阅更多信息。

如果你已经是一个 TypeScript 粉丝,你可能等不及要发表评论了。在评论之前,请先看看 “关于静态类型的惊人秘密”

要点:静态类型不会大幅减少整体的 bug 密度,但作为开发者工具你可能仍然喜欢它。

每个人总在谈论静态类型的所有好处,但是鲜有文章谈及它的缺点。那我们就来谈谈吧。

静态类型如何妨碍了可维护性

类型注释显然造成了更多语法噪声,并且这种语法让代码更难阅读和维护。然而缺点还不止这些。静态类型尤其让这些事情更难了(也不是不可能,只是更加复杂):

  • 重载函数与多态

  • 高阶函数

  • 对象组合

这就尴尬了,因为我这些特性我用得特别多,如果你是个熟练的 JavaScript 程序员,很可能你也用得很多。

我不想把这篇文章写成一本书,所以我会集中在最不能忍受的一点上:重载函数。

重载函数 是可以操作多种类型的参数的函数。例如,一个计算总和的函数可以应用于数字,但也可以应用于提供了 .valueOf() 方法来返回数字的对象。(注意,JS 中数字类型有 .valueOf() 方法,这就可以使用同样的内部逻辑来处理两种类型。)

C++ 或 Java 这样的语言中,重载函数需要类型构造器或模板接受类型参数,以实现编译时的函数多态。

你需要解析的语法变成多维的了,因为这个函数实际上是两个函数:一个参数化类型的函数和一个实际执行操作的函数。

在动态类型语言里,无需类型构造器。解析类型的工作被抽象了,开发者无需操心。类型解析依然存在,只不过发生在运行时,开发者没必要考虑它。

效果就是开发者不会被类型构造器或模板语法分心。相反,开发者可以使用鸭子类型(动态类型——译者注),并可选地执行运行时类型检查

动态类型并不是没有类型检查

运行时类型检查是一种发生在程序执行时的类型检查,这个时刻类型被实际用到。动态类型检查内置于 JavaScript,但非常宽松,只有在你尝试做一些疯狂的事情例如把 undefined 当做函数来调用时才会抛出错误。

它发生时,你的软件就崩溃了。还有可能搞挂你的生产服务器。真糟糕。

为了更严格的类型检查,你可以定义参数的类型,然后用类型检查函数包装你的函数。

例如,用 JavaScript 和 React,我们可以在开发模式中使用 React 的 PropTypes 来做自动的动态类型检查,同时在生产模式中把类型检查去掉。因为一旦你验证了传入了正确的类型,就不需要它了。

换言之,应用程序可以获得运行时类型检查的好处,同时性能又不受影响。

这里有个直观的例子,告诉你如何在 JavaScript 里加强运行时检查:

const fn = (fn, {required = []}) => (params = {}) => {
  const missing = required.filter(param => !(param in params));

  if (missing.length) {
    throw new Error(${ fn.name }() Missing required parameter(s):
    ${ missing.join(', ') });
  }

  return fn(params);
};

const createEmployee = fn(
  ({
    name = '',
    hireDate = Date.now(),
    title = 'Worker Drone'
  } = {}) => ({
    name, hireDate, title
  }),
  {
    required: ['name']
  }
);

console.log(createEmployee({ name: 'foo' })); // works
createEmployee(); // createEmployee() Missing required parameter(s): name

更智能的版本能够解析函数签名的文本,从默认参数中提取类型要求。例如,如果你试图传入一个 Date 对象而不是时间值给 hireDate,你可以抛出一个类型错误。

React 的 PropTypes 不像这样给函数加一层封装。由于 React 是一个框架,它可以在组件生命周期的任何阶段任意地、条件性地插入类型检查。其他框架可以实现类似的开发和线上行为的区分:

const check = fn => (params = []) => {
  const { required } = fn;
  const missing = required.filter(param => !(param in params));

  if (missing.length) {
    throw new Error(${ fn.name }() Missing required parameter(s):
    ${ missing.join(', ') });
  }

  return fn(params);
};

const createEmployee = ({
  name = '',
  hireDate = Date.now(),
  title = 'Worker Drone'
} = {}) => ({
  name, hireDate, title
});

createEmployee.required = ['name'];

// In development, a framework could wrap calls like this:
const emp1 = check(createEmployee)({ name: 'foo' }); // works
console.log(emp1);

// const emp2 = check(createEmployee)(); // would fail
// createEmployee() Missing required parameter(s):
//     name

// In production, the framework can call the function directly:
const emp3 = createEmployee({ name: 'foo' }); // no type check
console.log(emp3);

如果你觉得这看上去比静态类型更麻烦,考虑下通常被框架或库隐藏的运行时类型检查的复杂性。以上 app 的创建者只需要担心 createEmployee() 函数和它的 required 属性。换言之,相对于写一个同等的静态类型注释,它的工作要少些。

注:有一些开源的库就是用来做我刚才描述的工作的。这里有个实验性的项目,它可以轻松应用在任何 JS 程序里,跟不跟 React 一起用都可以。如果你想做贡献,查看 rfx.

类型错误会让我的 App 奔溃吗?

类型正确并不能保证程序正确。

类型错误不是 bug 的唯一来源,碰到 bug 抛出异常也不是最糟糕的事。

幸好你在使用 TDD 时听从了我建议。静态类型在捕获类型错误方面非常好,但在减少整体 bug 密度方面并没有什么卵用。。TDD 可以减少 40% 到 80% 的生产 bug。代码复查也是减少 bug 密度的一种有效方式。在代码复查上每花一个小时,就可以在维护上省下 33 个小时[1]。

如果你同时使用了 TDD 和代码复查,生产环境很少会有类型错误发生。

再加上运行时类型检查,你对 bug 就有了三层防护。我见过上百万行的代码使用这些策略, 产品 app 给上千万的用户服务, bug 密度非常低。

只要有人告诉你,你需要静态类型来支持大型的、复杂的应用,那都是瞎扯淡。一个高质量的持续交付方法,对 bug 密度和项目成功的影响,远比是否使用了静态类型大得多。

什么是鸭子类型?

鸭子类型 是一种关注值的结构而不是名字或类的类型检查方法。它类似对象的特性探测。如果它走起路来像只鸭子并且叫起来像只鸭子,我们就把它当做一只鸭子,即便它不是。

React 的 PropTypes 这样的系统在 JS 里是结构上的类型检查器,也就是根据它的形状(它的属性的名字和类型)而不是它的名字或来检查类型的。

类型错误只有在缺少需要的特性时才会出现。鸭子类型是有好处的,因为在设计阶段你无法预知程序未来的所有需求。如果将来你需要一个橡胶鸭子,同时也能像普通鸭子那样能飞也能嘎嘎叫,你怎么办?What if in the future you need a rubber duck drone which can still fly and quack like a regular duck?

利用名义上的静态类型,你需要在程序中所有使用“鸭子”的地方改变类型签名——这个过程不太容易通过自动重构工具来完成,因为需要改变的函数还不知道“鸭子”的存在。

利用鸭子类型和一定的结构类型系统,你不需要改变签名来适配“橡胶鸭子”。

概述

在泛型函数方面,静态类型在两个方面损害了可维护性:

  1. 在静态类型语言中,增加的模板复杂性和类型构造器让设计和理解泛型函数变得更难。

  2. 名义上的静态类型的限制让程序更难以在将来增加功能。

第二点不适用于结构性类型系统如 TypeScript,它也检查特性的可用性,而不是检查名称或一致性。它适用于名义上的类型系统,包括 Java 和 C++。

TypeScript 中的泛型函数

TypeScript 确实受到泛型带来的复杂性。来看看标准 JavaScript 里的泛型函数:

const identity = arg => arg;

比较下 TypeScript 中麻烦得多的静态类型函数:

function identity<T>(arg: T): T {
  return arg;
}

这些函数实际上都不需要任何类型信息,因为传递的值没有用到参数的任何特性。

动态类型系统可以利用数据流分析来推断和跟踪传递给标准 JavaScript 版的函数的参数类型。这是 Tern.jsFacebook’s Flow 这样的系统玩的魔术。(注:Flow 是一个带有动态推断和数据流分析能力的静态类型系统)

例如,Flow 不需要类型构造器来跟踪 identity 的类型:

const identity = arg => arg;
const num:number = identity('NaN LOL');
// "String. This type is incompatible with number"

Type Inference Rocks

ES6 中,函数可以指定默认值,这可以被兼容的类型推断系统如 Tern.js, TypeScript 和 Flow 用作类型推断。例如:

const createEmployee = ({
  name = 'Unnamed Employee',
  hireDate = Date.now(),
  title = 'Worker Drone'
}) => ({
  name,
  hireDate,
  title
});

以下是 atom-ternjs 显示的签名类型提示:

换言之,你只要使用标准的、动态类型的 JavaScript 代码配合一个可以推断类型的 IDE 工具,就可以享受到静态类型系统的 99 % 的好处。

TypeScript 非常棒的一点是,当推断功能不可避免地弄错签名时(当一个函数包装另一个函数时经常发生), TypeScript 允许你手动地将接口指向某个值,这样编辑器就能显示正确的提示了。

我希望 JavaScript 内置这种可选的方式。

自动重构呢?

两点:

  1. 可以解析依赖树以及拥有类型推断和数据流分析功能的任何开发工具,都可以完成你期望从静态类型工具那里获得的大部分自动化重构,如 Tern.js。

  2. 在几十年的软件开发过程中, 实质上可以在静态类型的辅助下完成,而需要我花几分钟手动重构某些东西的次数,我一只手可以数得过来。

自动完成呢?

每一个像样的编辑器都会有较好的自动完成插件。很多还在 Tern.js,Flow,等等的辅助下支持类型推断。下面是 Atom 自带的 autocomplete-plus 实际效果:

Atom 里的 autocomplete-plus

标识符名称书写错误呢?

任何像样的 linter 工具都可以捕捉到那些错误。这里是 ESLint 的实际效果:

ESLint 实际效果

结论

在我看来,很多静态类型倡导者似乎都没意识到现代动态类型工具的能力,例如动态类型检查以及利用数据流分析做类型推断。

如果你能得到静态类型 99% 的好处,而没有额外的语法噪点和类型标注的认知负担的话,静态类型真的能让你在开发效率上稳赚不赔吗?

静态类型真的值吗?

以我的经验,答案是值,也不值。

,是因为 TypeScript 的开发者工具目前比 Tern.js 和 Flow 的工具更好(我最近试过)。我说的更好是指 UI 更亲和。它更易于使用。在 linter 里类型错误像语法错误一样显示,格式很漂亮。超级有用。我有点爱上它了。你也会爱上它的。

在我看来,TypeScript 提供了当今 JavaScript 世界里最好的开发者工具体验。

如果 TypeScript 工具能够默认地给标准 JS 文件提供提示和类型推断,我就会用它,而不是 Tern.js。我会向所有人推荐这套工具。简单的选择。

注:Tern.js 和 Flow 没理由在开发者工具用户体验上输给 TypeScript。某人只需要研究下 编辑器/IDE 插件的细节呵护。

不值, 是因为以 TypeScript 今天的工作方式,你需要教会你的团队怎样合适地使用 TypeScript,以及如何尽可能保持代码远离语法噪点和标注,同时还能提供足够的类型线索和标注,让它物有所值。

我见过一个 TypeScript 生产项目有超过1000种类型错误,随处可见任何注释。 无论你听说 TypeScript 让运行大项目变得多容易,大项目都有大团队,开发人员教育和购买是软件开发中最困难的问题之一。

TypeScript 确实很赞 我真的很喜欢它。但是它带来了巨大的成本,忽略它是不明智的。 在你决定使用TypeScript之前,请仔细看看你的团队,并非常仔细和诚实地问自己:

  • 你的团队准备好面对学习曲线了吗?

  • 你愿意在训练和指导开发者上保持投入的纪律,以加快 TypeScript 的开发速度了吗?

  • 在未来招聘新员工后还能保持同样的投入纪律吗?

  • 从投资回报率角度来看,最重要的是: TypeScript 提供的这么点改进,真的值得付出那些额外的工夫?

或者说,

  • 标准的 JS + Tern.js + ESLint + autocomplete + TDD + Code Review 组合里99% 的优势对你的团队是否足够?

提示:即使用上静态类型,你仍然需要 lint,TDD 和code review,这样才能大幅减少生产 bug。

总之:毫无疑问,静态类型可以感觉很好。 咬入一个热的玻璃多福饼感觉很好。但是它真的对你有好处吗?


  1. Russell, Glen W. “Experience with Inspection in Ultralarge-Scale Developments,” IEEE Software, Vol. 8, №1 (January 1991), pp. 25–31.

相关文章