
在谈论“错误代码”时,人们几乎肯定会在其他流行问题中指“复杂代码”。 关于复杂性的事情是无处不在。 有一天,您开始了相当简单的项目,另一天,您发现了它的废墟。 而且没人知道它是如何发生的以及何时发生的。
但是,这最终是有原因的! 代码复杂度通过两种可能的方式进入您的代码库:大块和增量添加。 而且人们不善于审查和发现它们两者。
当有大量代码进入时,审阅者将被要求查找代码复杂的确切位置以及处理方法。 然后,审查将必须证明这一点:为什么此代码首先很复杂。 其他开发人员可能会不同意。 我们都知道这类代码审查!

进入代码的第二种复杂性方法是增量加法:向现有函数提交一两行时。 很难注意到您的函数在一个提交之前就可以了,但是现在它太复杂了。 要真正发现它,需要花费很大的精力,复习技巧和良好的代码导航实践。 大多数人(像我一样!)缺乏这些技能,并允许复杂性定期输入代码库。
因此,如何防止代码变得复杂? 我们需要使用自动化! 让我们深入研究代码的复杂性以及找到并最终解决它的方法。
在本文中,我将指导您遍历复杂性生活的地方以及如何与之抗争。 然后,我们将讨论编写良好的简单代码和自动化如何为“连续重构”和“按需架构”开发风格提供机会。
复杂性解释
有人可能会问:“代码复杂性”到底是什么? 虽然听起来很熟悉,但在理解复杂度的确切位置方面存在隐藏的障碍。 让我们从最原始的部分开始,然后转到更高层次的实体。
还记得本文被命名为“复杂性瀑布”吗? 我将向您展示从最简单的原语到最高抽象的复杂性。
我将使用python
作为示例的主要语言,并使用wemake-python-styleguide
作为主要wemake-python-styleguide
工具,以查找代码中的违规并阐明我的观点。
表达方式
您的所有代码都包含简单的表达式,例如a + 1
和print(x)
。 尽管表达式本身很简单,但是它们有时可能会在一定程度上引起代码的复杂性溢出。 示例:假设您有一个代表某些User
模型的字典,并且您像这样使用它:
def format_username(user) -> str: if not user['username']: return user['email'] elif len(user['username']) > 12: return user['username'][:12] + '...' return '@' + user['username']
看起来很简单,不是吗? 实际上,它包含两个基于表达式的复杂性问题。 它overuses 'username'
字符串,并使用了魔幻数字 12
(为什么我们首先使用这个数字,为什么不使用13
或10
?)。 很难自己找到所有这些东西。 更好的版本如下所示:
表达也有不同的问题。 我们还可以使用一些过度使用的表达式 :在各处使用some_object.some_attr
属性而不是创建新的局部变量时。 我们也可能拥有过于复杂的逻辑条件或过于深入的点访问 。
解决方案 :创建新的变量,参数或常量。 如果需要,请创建并使用新的实用程序功能或方法。
线数
表达式形成代码行(请不要将行与语句混淆:单个语句可以占用多行,并且多个语句可能位于一行上)。
线的第一个也是最明显的复杂性度量是其长度。 是的,您没听错。 这就是为什么我们(程序员)更喜欢遵循每行80
字符的规则,而不是因为它以前是在电传打字机中使用的。 最近有很多关于它的谣言,说在2k19中使用80
字符作为代码没有任何意义。 但是,那显然不是事实。
这个想法很简单。 行数为160
字符时,逻辑的数量是行数为80
字符时的逻辑数量的两倍。 这就是为什么应该设置并强制执行此限制。 请记住,这不是样式选择 。 这是一个复杂性指标!
第二种主线复杂性度量标准鲜为人知,使用较少。 这称为琼斯复杂度 。 它背后的想法很简单:我们在一行中计算代码(或ast
)节点以获取其复杂性。 让我们看一个例子。 这两行的复杂度从根本上不同,但字符的宽度完全相同:
print(first_long_name_with_meaning, second_very_long_name_with_meaning, third) print(first * 5 + math.pi * 2, matrix.trans(*matrix), display.show(matrix, 2))
让我们计算第一个节点:一个调用,三个名称。 总共四个节点。 第二个节点有21个ast
节点。 好吧,区别很明显。 这就是为什么我们使用琼斯复杂度度量标准来允许第一条长行并基于内部复杂性而不是仅基于原始长度来禁止第二条长行。
琼斯复杂度得分高的线怎么办?
解决方案 :将它们分成几行,或创建新的中间变量,实用函数,新类等。
print( first * 5 + math.pi * 2, matrix.trans(*matrix), display.show(matrix, 2), )
现在,它更具可读性!
结构体
下一步是分析由线条和表达式组成的语言结构,如if
, for
, with
,等。 我不得不说,这一点是非常特定于语言的。 我还将使用python
展示该类别的一些规则。
我们将从if
开始。 if
比一个好孩子更容易呢? 实际上, if
真的开始变得棘手的话。 这是一个如何使用if
reimplement switch
的示例:
if isinstance(some, int): ... elif isinstance(some, float): ... elif isinstance(some, complex): ... elif isinstance(some, str): ... elif isinstance(some, bytes): ... elif isinstance(some, list): ...
这段代码有什么问题? 好吧,想象一下,我们应该涵盖数十种数据类型,包括我们尚不了解的海关数据类型。 然后,这个复杂的代码表明我们在这里选择了错误的模式。 我们需要重构代码以解决此问题。 例如,可以使用singledispatch
es或singledispatch
。 他们做的一样,但是更好。
python
永不停止逗我们。 例如,您可以编写任意数量的case ,这在思想上过于复杂和令人困惑:
with first(), second(), third(), fourth(): ...
您还可以使用任意数量的if
和for
表达式编写理解,这可能导致复杂的,不可读的代码:
[ (x, y, z) for x in x_coords for y in y_coords for z in z_coords if x > 0 if y > 0 if z > 0 if x + y <= z if x + z <= y if y + z <= x ]
将其与简单易读的版本进行比较:
[ (x, y, z) for x, y, x in itertools.product(x_coords, y_coords, z_coords) if valid_coordinates(x, y, z) ]
您还可以multiple statements inside a try
案例中意外地包含multiple statements inside a try
,这是不安全的,因为它可以在预期的位置引发并处理异常:
try: user = fetch_user()
而且即使是10%的情况,您的python
代码也可能会出错。 还有许多更多的边缘情况需要跟踪和分析。
解决方案 :唯一可能的解决方案是为您选择的语言使用优质棉绒 。 并重构此短绒棉突出的复杂场所。 否则,您将不得不重新发明轮子并为完全相同的问题设置自定义策略。
功能介绍
表达式,语句和结构形成函数。 这些实体的复杂性转化为功能。 这就是事情开始吸引人的地方。 因为函数实际上具有数十种复杂性指标:好坏。
我们将从最著名的代码开始: 圈复杂度和以代码行衡量的函数长度。 循环复杂度表明您的执行流程可以执行多少次:它几乎等于完全覆盖源代码所需的单元测试数。 这是一个很好的指标,因为它尊重语义并帮助开发人员进行重构。 另一方面,函数的长度是不好的指标。 由于我们已经知道,它与先前解释的琼斯复杂度度量标准不相称:多行比内部所有内容的大行更容易阅读。 我们将只关注好的指标,而忽略坏的指标。
根据我的经验,应该计算多个有用的复杂性指标,而不是常规函数的长度:
- 功能装饰器的数量; 越低越好
- 参数数量; 越低越好
- 批注数量; 越高越好
- 局部变量数; 越低越好
- 回报,收益,等待数量; 越低越好
- 语句和表达式的数量; 越低越好
所有这些检查的组合实际上使您可以编写简单的函数(所有规则也适用于方法)。
当您尝试使用功能进行一些令人讨厌的事情时,您肯定会破坏至少一个指标。 这会让我们的小子失望,炸毁你的身材。 结果,您的功能将被保存。
解决方案 :当一个功能过于复杂时,您唯一需要解决的就是将该功能拆分为多个功能。
班级
函数之后的下一个抽象级别是类。 正如您已经猜到的那样,它们比函数还要复杂和灵活。 因为类内部可能包含多个函数(称为方法),并且具有其他独特功能,如继承和混合,类级属性和类级装饰器。 因此,我们必须将所有方法都检查为函数和类主体本身。
对于班级,我们必须衡量以下指标:
- 类装饰器的数量; 越低越好
- 基类数量; 越低越好
- 类级公共属性的数量; 越低越好
- 实例级公共属性的数量; 越低越好
- 方法数量; 越低越好
如果其中任何一个过于复杂-我们都必须响起警报并使构建失败!
解决方案 :重构您失败的课程! 将一个现有的复杂类拆分为几个简单的类,或创建新的实用程序函数并使用组合。
值得注意的是:还可以跟踪内聚和耦合度量,以验证OOP设计的复杂性。
模组
模块确实包含多个语句,函数和类。 正如您可能已经提到的,我们通常建议将函数和类拆分为新的函数和类。 这就是我们必须关注模块复杂性的原因:它实际上是从类和函数流入模块的。
要分析模块的复杂性,我们必须检查:
- 进口数量和进口名称; 越低越好
- 类和功能的数量; 越低越好
- 内部函数和类的平均复杂度; 越低越好
如果是复杂的模块,我们该怎么办?
解决方案 :是的,您做对了。 我们将一个模块分成几个模块。
配套
软件包包含多个模块。 幸运的是,这就是他们所做的全部。
因此,包中的模块数量很快就会开始变得太大,因此最终您将拥有太多的模块。 这是可以在包中找到的唯一复杂性。
解决方案 :您必须将程序包分为子程序包和不同级别的程序包。
复杂性瀑布效应
现在,我们已经涵盖了代码库中几乎所有可能的抽象类型。 我们从中学到了什么? 到目前为止,主要的收获是,可以通过将复杂性提高到相同或更高的抽象级别来解决大多数问题。

这使我们引出本文最重要的想法:不要让您的代码充满复杂性。 我将举几个例子说明它通常如何发生。
想象一下您正在实现一项新功能。 这是您所做的唯一更改:
看起来还不错,我会在审核时通过此代码。 不会发生任何不良情况。 但是,我缺少的一点是复杂性溢出了这条线! 这就是wemake-python-styleguide
将报告的内容:

好的,我们现在必须解决这种复杂性。 让我们创建一个新变量:
class Product(object): ... def can_be_purchased(self, user_id) -> bool: ... is_sub_paid = sub.is_due(tz.now() + delta) if user.is_active and user.has_sub() and is_sub_paid: ... ... ...
现在,解决了线路复杂性。 但是,请稍等。 如果我们的函数现在变量太多了怎么办? 因为我们创建了一个新变量,但没有先检查函数内部的编号。 在这种情况下,我们将不得不将此方法拆分为几个方法,如下所示:
class Product(object): ... def can_be_purchased(self, user_id) -> bool: ... if self._has_paid_sub(user, sub, delta): ... ... def _has_paid_sub(self, user, sub, delta) -> bool: is_sub_paid = sub.is_due(tz.now() + delta) return user.is_active and user.has_sub() and is_sub_paid ...
现在我们完成了! 对不对 不,因为我们现在必须检查Product
类的复杂性。 想象一下,自从我们创建了一个新的_has_paid_sub
以来,它现在拥有太多的方法。
好的,我们运行linter来再次检查复杂性。 事实证明,我们的Product
类现在确实太复杂了。 我们的行动? 我们将其分为几类!
class Policy(object): ... class SubcsriptionPolicy(Policy): ... def can_be_purchased(self, user_id) -> bool: ... if self._has_paid_sub(user, sub, delta): ... ... def _has_paid_sub(self, user, sub, delta) -> bool: is_sub_paid = sub.is_due(tz.now() + delta) return user.is_active and user.has_sub() and is_sub_paid class Product(object): _purchasing_policy: Policy ... ...
请告诉我这是最后一次迭代! 好吧,很抱歉,但是我们现在必须检查模块的复杂性。 你猜怎么着? 现在我们有太多的模块成员。 因此,我们必须将模块拆分为单独的模块! 然后我们检查程序包的复杂性。 并且也可能将其拆分为几个子包。
你看到了吗? 由于定义良好的复杂性规则,我们的单行修改原来是一个包含几个新模块和类的巨大重构会话。 而且我们自己还没有做出一个决定:我们所有的重构目标都是由内部复杂性和揭示它的棉绒驱动的。
这就是我所说的“连续重构”过程。 您被迫进行重构。 总是
这个过程也有一个有趣的结果。 它使您可以拥有“按需架构”。 让我解释一下。 有了“按需架构”的理念,您总会从小做起。 例如,使用单个logic/domains/user.py
文件。 然后您开始将所有与User
相关的内容放到这里。 因为此时您可能不知道您的体系结构是什么样。 而且你不在乎。 您只有三种功能。
有些人陷入架构与代码复杂性陷阱之间。 从一开始,他们就可能使整个体系结构/服务/域层的体系结构过于复杂。 或者,它们可能会使源代码过于复杂,而没有明确的分隔。 像这样努力奋斗并生活多年(如果他们能够使用这样的代码生活多年!)。
“按需架构”的概念解决了这些问题。 当时间到来时,您从小处开始-拆分并重构事物:
- 您从
logic/domains/user.py
开始,然后将所有内容放入其中 - 稍后,当您具有足够的数据库相关内容时,可以创建
logic/domains/user/repository.py
/repository.py - 然后,当复杂性告诉您这样做时,将其拆分为
logic/domains/user/repository/queries.py
和logic/domains/user/repository/commands.py
logic/domains/user/repository/queries.py
- 然后使用
http
相关的内容创建logic/domains/user/services.py
/services.py - 然后创建一个新的模块,称为
logic/domains/order.py
- 依此类推
就是这样 它是平衡您的体系结构和代码复杂性的理想工具。 并获得您当前真正需要的尽可能多的体系结构。
结论
好的短毛绒比发现缺少逗号和错误的报价要多得多。 优良的短毛绒使您可以依靠它来进行体系结构决策,并帮助您进行重构过程。
例如, wemake-python-styleguide
可能会帮助您解决python
源代码的复杂性,它使您能够:
不要让复杂性溢出您的代码,请使用优质的lint !