编程过度防御?只有缺乏自信的程序员才会这样做
点击上方“CSDN”,选择“置顶公众号”
关键时刻,第一时间送达!
最近我问一个同事为什么需要做某项检查,他耸耸肩说,“为了保险起见。”在工作中,我见过很多代码都是为了保险起见而写的。我自己也写过这样的代码!如果不确定是否存在依赖性,那么我肯定会加上一个检查,以防抛异常。
我在这里所说无效代码指的是在代码中加入了非必要的默认值,比如下面的代码。
axios.get(url).then(({ data }) =>
// If the response doesn't have a document, use an empty object
this.setState({ document: data.document || {} });
})
或者一层层深入检查数据中每个key的存在性。
render() {
const { document } = this.state;
const title = document &&
document.page &&
document.page.heading &&
document.page.heading.title;
return <h1>{title}</h1>
}
还有其他无效代码,比如防止抛异常。我们常常无意中抑制异常的发生,就像在墙上挂幅画掩盖后面的洞。
乍一看,这并没有什么问题。但是这种做法并没有把洞补好,同样也没有改好 bug。而且写入程序的不再是易于追踪的异常,而是非法的数据。如果后端部署错误造成返回了一个空的响应呢?代码就会使用那些默认值,而一连串的 && 检查都会返回 undefined,那么最后页面上就会显示 undefined。在 React 代码中,页面根本得不到渲染。
计算中有一句格言:“大度地接受,保守地发送。”有些人可能认为这些原则都有实际应用的例子,但是我不太同意。我认为,如果过度使用这些模式,会对代码库以及服务提供的保障缺乏正确的理解。
来自第三方的数据或参数
你的代码对他人代码的期望可以看成是一种合约。通常,这个合约只是隐含的,但是应该注意数据的格式并记录在文档中。没有充分理解并清晰地记录下来的 API 返回数据格式,一旦出错,如何判断是谁的代码出了问题呢?明确的定义可以建立彼此的信任。
当向一个外部的 HTTP API 发送请求的时候,并不需要检查 response 对象是否包含了 data。data 必然存在,因为这是你和请求库之间的合约。举例来说,axios 的文档定义了返回的响应数据的格式。而且 response 返回的数据结构应该也是已知的。除非请求是有状态的,或遇到了错误,否则响应的内容永远是一样的。这就是你和后台建立的合约。
在应用程序内传递数据
你编写的函数和创建的类也是一种合约,但是这个合约由你这个开发者来保证。你需要对自己的数据有信心,代码会变得更加可预测,而且故障原因会更加明显。如果抛出的异常位于错误数据的附近,那么调试数据错误就会变得更简单。
非必要性的安全意味着, 45 33010 45 14939 0 0 1211 0 0:00:27 0:00:12 0:00:15 2824函数可以继续悄悄地传递错误数据,直到遇到一个没有过度防御的函数。这会导致故障在应用程序中某个地方演变成奇怪的行为,而自动化工具很难检测到这类的问题。调试工作的难度也会加大,因为你必须一路追溯,直到找到错误数据的发生源头。
我在代码沙盒中建立了一个过度防御与不安全访问的例子。
const initialStuff = {
things: {
meta: {
title: "I'm so meta, even this acronym",
description: "will throw an error if you break the data"
}
}
};
// And within each component,
handleClick = e => {
if (this.state.stuff) {
this.setState({ stuff: null });
} else {
this.setState({ stuff: initialStuff });
}
};
下面的“安全”代码可以防止抛出异常:
const { title, description } =
(stuff && stuff.things && stuff.things.meta) || {};
而如下的不安全代码可以不经检查赋值:
const { title, description } = this.state.stuff.things.meta;
性能与开发速度
除此之外,条件逻辑的使用是有代价的。分开来看,这些代码对性能的影响很小,但是如果这种重复非必要检查的编程习惯广泛蔓延,那么就会产生显著的耗时。这种影响是巨大的:React 的生产模式会删除 prop types 检查,从而获得性能的显著提高。一些测试数据显示 React 15 的生产模式的速度比开发模式提高可以 2-4 倍。
条件逻辑还会增加开发人员思想上的负担,这会影响所有依赖于此模块的代码。谨慎对待外部数据意味着下一个使用数据的人不知道它是否值得信赖。如果没有深入研究数据的来源,检查数据的可信度,那么最安全的选择是将其视为不安全。因此,此代码的行为将迫使其他开发人员将其视为不可信,甚至会感染所有新编写的代码。
如何解决这个问题
当我们写代码的时候,应该花些时间考虑一下极端的例子。
可能发生什么样的错误?什么样的数据会引发这些错误?
是否可以处理预见的错误?
生产环境中可能发生这样的错误吗?或者只是在开发中会遇到这样的错误?
如果提供默认值,是否可以在后续代码中正确地使用?
很多纠正这个问题的方式都是处理可以解决的错误,并抛出无法解决的错误。验证外部 API 返回的数据是否符合期望的结构固然没错,但是如果不验证,你的应用还能不能正常运行?应该依靠错误处理,并向用户做出恰当的响应,同时将错误信息写入日志,通知开发人员错误的发生。
掌握外部工具可以提供什么输出是编写值得信赖的代码中很重要的一部分。在大多数情况下文档会清晰地交代,但是有的时候仅仅是隐含。后台 API 的数据格式由后台代码的作者决定。如果你是全栈开发,那么很好,你可以同时控制前端和后台,而且你可以相信自己(对吧?)。如果后台的 API 由不同的团队负责,那么你需要定义正确的行为,并且互相支持。第三方的 API 更加难以让人信赖,但是你也很少能够影响到它的返回值。
在写 React 组件的时候,你有更加强大的工具:PropTypes。你可以省略一系列的检查,比如 a && a.b && a.b.c && typeof a.b.c === 'function' && a.b.c(),只需加入一个静态属性的类型定义,如下所示:
Thing.propTypes = {
a: PropTypes.shape({
b: PropTypes.shape({
c: PropTypes.func.isRequired
}).isRequired
}).isRequired
};
这段代码看上去不太好看,但是这个组件可以在开发期间记录错误的数据。遗漏的数据可能往后抛它自己的错误,那么你觉得下列哪个信息更加有帮助?
Warning: Failed prop type: The prop 'a' is marked as required in 'Thing', but its value is 'undefined'.
还是,
Uncaught TypeError: Cannot read property 'b' of undefined
外部数据可能变化
当然,有时你并不能确认手中掌握的数据。数据中可能包含 a, b, c 或 x, y, z 等键,又或者 data 可能为 null。这种情况下最好还是检查一下,但是可以考虑把检查写成函数:
const hasDataLoaded = data => typeof data !== "undefined";
hasDataLoaded(data) && data.map(/* … */);
命名良好的函数可以向后来的同事展示为什么加入了这些检查。个别非常好的命名可以帮助他们在将来进行更精准地检查。
过分注重安全的无效代码,甚至是考虑周全的检查,也只是在防范数据类型的错误而已。PropTypes 可以很容易地添加到已有的 React 代码中,但这并不是唯一的可选方案。可以用 TypeScript 和 Flow 等更高级的工具来验证数据的类型。PropTypes 可以在运行时拯救你的代码,但 TypeScript 或 Flow 则可以在开发的时候验证代码。类型可以在代码中硬性规定:如果你不正确使用数据,那么甚至无法通过编译!
也许有些人并不喜欢类型,但是对我来说,类型的应用很广泛、又非常复杂、是代码中很难改变的部分。而对于剩下的代码,至少在 React 中,PropTypes 可以帮助你迅速定位错误,并让你对自己的代码更自信。
当一个开发人员编写“为了保险起见”的代码的时候,这就意味着他们可能对某些东西不够了解。如果无视这种情况,那么小问题就会日益积累,最终发展成大问题。在做改变的时候,必须掌握你想要什么样的错误信息,如何防御那些不需要的错误,并对自己的代码建立信心。
原文:https://medium.com/@cvitullo/overly-defensive-programming-e7a1b3d234c2
作者:Carl Vitullo
译者:马晶慧
责编:言则
————— 推荐阅读 —————
点击图片即可阅读