复杂性瀑布
最初发表在我的博客:https://sobolevn.me/2019/10/complexity-waterfall
当人们谈论“糟糕的代码”时,几乎肯定会想到“复杂代码”以及其他一些常见问题。复杂性在于它总是突如其来。今天你启动了一个相当简单的项目,明天你却发现它已经一片狼藉。而且没人知道这一切是如何发生的,又在何时发生。
但这一切的发生都是有原因的!代码复杂性会以两种可能的方式进入你的代码库:大块代码的添加和增量添加。而人们往往不擅长审查和发现这两种情况。
当收到大量代码时,审查人员面临的挑战是找出代码复杂度的确切位置,并找到相应的解决方案。然后,审查人员必须证明这一点:这段代码复杂度究竟有多高。其他开发人员可能不同意这种说法。我们都知道这类代码审查!
代码复杂性的第二种方式是渐进式增加:当你向现有函数提交一两行代码时。很难注意到你的函数在一次提交之前还很好,但现在却变得过于复杂了。需要高度集中注意力、代码审查技巧以及良好的代码导航习惯才能真正发现这一点。大多数人(比如我!)都缺乏这些技能,导致复杂性频繁地进入代码库。
那么,如何才能防止代码变得复杂呢?我们需要自动化!让我们深入探讨代码复杂性,并找到最终解决问题的方法。
在本文中,我将引导您了解复杂性存在的地方以及如何应对它。然后,我们将讨论如何通过编写简洁的代码和自动化来实现“持续重构”和“按需架构”的开发风格。
复杂性解释
有人可能会问:“代码复杂度”到底是什么?虽然听起来很熟悉,但在理解复杂度的确切位置上却隐藏着一些障碍。让我们从最原始的部分开始,然后再讨论更高层次的实体。
还记得这篇文章的名字叫“复杂性瀑布”吗?我会向你展示复杂性是如何从最简单的基元溢出到最高的抽象的。
我将使用它python作为我的示例的主要语言和wemake-python-styleguide主要的 linting 工具来查找我的代码中的违规行为并说明我的观点。
表达式
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']
看起来挺简单的,不是吗?其实,它包含两个基于表达式的复杂性问题。它过度使用了'username'字符串,并且使用了魔法数字 12(我们为什么要用这个数字,为什么不用13or 呢10?)。你自己很难找到这些东西。更好的版本应该是这样的:
#: That's how many chars fit in the preview box.
LENGTH_LIMIT: Final = 12
def format_username(user) -> str:
username = user['username']
if not username:
return user['email']
elif len(username) > LENGTH_LIMIT: # See? It is now documented
return username[:LENGTH_LIMIT] + '...'
return '@' + username
表达式也存在各种问题。表达式也可能被过度使用:比如到处使用属性,而不是创建新的局部变量。逻辑条件some_object.some_attr也可能过于复杂,或者点访问过于深奥。
解决方案:创建新的变量、参数或常量。如果需要,请创建并使用新的实用函数或方法。
线条
表达式形成代码行(请不要将行与语句混淆:单个语句可以占用多行,并且多个语句可能位于一行上)。
一行代码的首要且最明显的复杂度指标是它的长度。是的,你没听错。这就是为什么我们(程序员)更喜欢坚持80每行字符数的规则,而不是因为它以前在电传打字机中使用过。最近有很多关于它的谣言,说80在2K19中,在代码中使用字符数没有任何意义。但这显然是错误的。
这个想法很简单。一行包含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))
我们来数一下第一个例子中的节点数:一个电话,三个名字。总共四个节点。第二个例子中有二十一个ast节点。嗯,区别很明显。这就是为什么我们使用琼斯复杂度指标来允许第一个长行,并根据内部复杂度(而不仅仅是原始长度)来禁止第二个长行。
如何处理琼斯复杂度分数较高的线条?
解决方案:将它们分成几行或创建新的中间变量、实用函数、新类等。
print(
first * 5 + math.pi * 2,
matrix.trans(*matrix),
display.show(matrix, 2),
)
现在它的可读性更强了!
结构
下一步是分析由行和表达式构成的语言结构,例如,,,if等等。我必须说,这一点与语言本身息息相关。我也会使用 来展示这类规则中的几条。forwithpython
我们先从 开始if。还有什么比老套的 更简单呢if?实际上,if很快就会变得棘手。以下是如何使用重新实现switchif的示例:
if isinstance(some, int):
...
elif isinstance(some, float):
...
elif isinstance(some, complex):
...
elif isinstance(some, str):
...
elif isinstance(some, bytes):
...
elif isinstance(some, list):
...
这段代码有什么问题?想象一下,我们有几十种数据类型需要覆盖,包括一些我们尚不清楚的自定义类型。那么这段复杂的代码就表明我们选择了一个错误的模式。我们需要重构代码来解决这个问题。例如,可以使用typeclasses或singledispatch。它们的作用相同,但更简洁。
python总是能给我们带来乐趣。例如,你可以写with任意数量的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)
]
您还可能意外地在一个案例中包含多个语句try,这是不安全的,因为它可能会在预期的位置引发和处理异常:
try:
user = fetch_user() # Can also fail, but don't expect that
log.save_user_operation(user.email) # Can fail, and we know it
except MyCustomException as exc:
...
而这还不到代码中可能出错情况的 10% python。还有更多边缘情况需要跟踪和分析。
解决方案:唯一可行的方案是使用一款针对你所选语言的优秀 linter。并重构这款 linter 突出显示的复杂位置。否则,你将不得不重新设计轮子,并为同样的问题设置自定义策略。
功能
表达式、语句和结构构成了函数。这些实体的复杂性最终会流入函数。而这正是事情开始变得引人入胜的地方。因为函数实际上有几十个复杂性指标:好的和坏的。
我们将从最广为人知的指标开始:圈复杂度和函数长度(以代码行数衡量)。圈复杂度表示执行流程可以经过多少次循环:它几乎等于完全覆盖源代码所需的单元测试数量。这是一个很好的指标,因为它尊重语义并帮助开发人员进行重构。另一方面,函数长度是一个不好的指标。它与之前解释的琼斯复杂度指标不符,因为我们已经知道:多行代码比一行包含所有内容的长代码更容易阅读。我们将只关注好的指标,而忽略不好的指标。
根据我的经验,应该计算多个有用的复杂性指标,而不是常规函数的长度:
- 函数装饰器的数量;越低越好
- 参数数量;越低越好
- 注释数量;越多越好
- 局部变量的数量;越低越好
- 回报数量、收益率、等待时间;越低越好
- 语句和表达式的数量;越低越好
所有这些检查的组合确实允许您编写简单的函数(所有规则也适用于方法)。
当你尝试对函数进行一些恶意操作时,你肯定会破坏至少一个指标。这会让我们的 linter 测试失败,并破坏你的构建。因此,你的函数可以得救。
解决方案:当一个功能过于复杂时,唯一的解决方案就是将该功能拆分为多个功能。
课程
函数之后的下一个抽象层级是类。正如你所猜测的,它们比函数更加复杂和灵活。因为类内部可能包含多个函数(称为方法),并且还具有其他独特的特性,例如继承和混合宏、类级属性和类级装饰器。因此,我们必须将所有方法和类主体本身都视为函数进行检查。
对于课程,我们必须测量以下指标:
- 类级装饰器的数量;越低越好
- 基类数量;越低越好
- 类级公共属性的数量;越低越好
- 实例级公共属性的数量;越低越好
- 方法数量;越少越好
当其中任何一个过于复杂时 - 我们都必须敲响警报并导致构建失败!
解决方案:重构失败的类!将一个现有的复杂类拆分成几个简单的类,或者创建新的实用函数并使用组合。
值得一提的是:还可以跟踪内聚力和耦合指标来验证 OOP 设计的复杂性。
模块
模块确实包含多个语句、函数和类。正如您可能已经提到的,我们通常建议将函数和类拆分成新的。这就是为什么我们必须密切关注模块的复杂性:它实际上会从类和函数流入模块。
为了分析模块的复杂性,我们必须检查:
- 进口数量和进口名称;越低越好
- 类和函数的数量;越低越好
- 内部函数和类的平均复杂度;越低越好
如果模块复杂,我们该怎么办?
解答:是的,你答对了。我们把一个模块拆分成了几个。
套餐
软件包包含多个模块。幸运的是,它们的作用仅限于此。
所以,一个包中的模块数量很快就会变得过多,最终导致包过多。而这正是包中唯一存在的复杂性。
解决方案:您必须将包分成子包和不同级别的包。
复杂瀑布效应
现在,我们已经涵盖了代码库中几乎所有可能的抽象类型。我们从中学到了什么?目前的主要收获是,大多数问题都可以通过将复杂性提升到相同或更高的抽象级别来解决。
这就引出了本文最重要的观点:不要让你的代码过于复杂。我会举几个例子来说明这种情况通常如何发生。
想象一下,你正在实现一项新功能。这是你唯一要做的改变:
+++ if user.is_active and user.has_sub() and sub.is_due(tz.now() + delta):
--- if user.is_active and user.has_sub():
看起来还不错,我会把这段代码提交审核,而且不会有什么问题。但是,我忽略了一点:复杂性溢出了这一行!这就是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
...
...
拜托,告诉我这是最后一次迭代!好吧,很抱歉,我们现在必须检查模块复杂度。你猜怎么着?我们现在的模块成员太多了。所以,我们必须把模块拆分成独立的模块!然后我们检查包的复杂度。也可能把它拆分成几个子包。
你看到了吗?由于明确定义的复杂性规则,我们原本只是一行代码的修改,结果却变成了一场浩大的重构,涉及多个新的模块和类。而且我们自己并没有做出任何决定:所有重构目标都由内部复杂性以及揭示复杂性的linter工具驱动。
这就是我所说的“持续重构”过程。你必须不断进行重构。
这个过程还有一个有趣的结果。它让你能够“按需构建架构”。让我来解释一下。秉承“按需构建架构”的理念,你总是从小处着手。例如,从一个logic/domains/user.py文件开始。然后开始把所有相关的东西都放在User那里。因为现在你可能还不知道你的架构最终会是什么样子。而且你也不关心。你只有三个函数。
有些人陷入了架构与代码复杂度的陷阱。他们可能从一开始就用完整的存储库/服务/域层来过度复杂化架构。或者,他们可能在没有明确划分的情况下过度复杂化源代码。他们会挣扎求生,并像这样生活很多年(如果他们能忍受这样的代码生活很多年的话!)。
“按需架构”的概念解决了这些问题。你可以从小处着手,到需要的时候再拆分和重构:
- 你开始
logic/domains/user.py把所有东西都放在那里 - 稍后
logic/domains/user/repository.py当您拥有足够的数据库相关内容时,您就可以创建 - 然后你把它分成
logic/domains/user/repository/queries.py,logic/domains/user/repository/commands.py当复杂性告诉你这样做的时候 - 然后你
logic/domains/user/services.py用http相关的东西来创作 - 然后创建一个名为
logic/domains/order.py - 等等等等
就是这样。它是平衡架构和代码复杂性的完美工具。并且能够满足您当前真正需要的架构需求。
结论
好的 linter 功能远不止查找缺失的逗号和错误的引号。好的 linter 可以让你依靠它进行架构决策,并帮助你完成重构过程。
例如,wemake-python-styleguide它可以帮助您解决python源代码的复杂性,它允许您:
- 成功应对各个层面的复杂性
- 执行大量命名标准、最佳实践和一致性检查
- 借助
diff选项或flakehell工具轻松将其集成到遗留代码库中,这样旧的违规行为将被原谅,但新的违规行为将不被允许 - 将其启用到您的CI中,甚至作为Github Action
不要让复杂性溢出您的代码,使用好的 linter!
鏂囩珷鏉ユ簮锛�https://dev.to/wemake-services/complexity-waterfall-n2d


