由 Heart 编译
在机器学习中,我们经常需要使用类和函数来定义模型的各个部分,例如读取数据的函数、预处理数据的函数、模型架构和训练过程。那么什么样的函数才是美观且令人赏心悦目的呢?在本文中,Jeff Knupp 从命名到代码大小六个方面讨论了如何开发美观的函数。
与大多数现代编程语言一样,函数是编程语言中抽象和封装的基本方法之一。在开发阶段,你可能编写了数百个函数,但并不是每个函数都是平等的。编写“糟糕”的函数会直接影响代码的可读性和可维护性。那么,什么样的函数才是“糟糕”的函数呢?更重要的是,如何编写一个“好”的函数?
简要回顾
数学中充满了函数,尽管我们可能记不住它们。让我们首先回顾一下大家最喜欢的主题——微积分。你可能还记得这个等式:f(x) = 2x + 3。这是一个称为“f”的函数,它接受一个未知数 x 并“返回”2*x+3。这个函数可能看起来不像我们在书中看到的,但基本思想与计算机语言中的函数相同。
函数在数学中有着悠久的历史,但在计算机科学中则更为强大。然而,函数也存在一些缺陷。接下来,我们将讨论什么是“好的”函数,以及哪些迹象表明我们需要重构我们的函数。
判断功能好坏的关键
什么区分了好函数和坏函数?令人惊讶的是,“好”函数的定义有很多。为了我们的目的,我将好函数定义为满足以下列表中大多数规则的函数(其中一些规则更难实现):
对于很多人来说,这个列表可能有点太严格了。但我保证,如果你的函数遵循这些规则,你的代码会看起来非常漂亮。下面我将逐步解释每条规则,然后总结这些规则如何构成一个“好的”函数。
姓名
关于这个话题我最喜欢的一句话(来自 Phil,经常被误认为是 Knuth 说的)是:
计算机科学中只有两个难题:缓存失效和命名事物。
听起来有点奇怪,但起一个好名字真的很难。下面是一个糟糕的函数名:
def get_knn(from_df):
我基本上在任何地方都见过错误的命名,但这个例子来自数据科学(或者更确切地说,机器学习),从业者总是分层编写代码,然后尝试将这些不同的单元转换成可理解的程序。
函数命名的第一个问题是首字母缩略词/缩写的使用。完整的英文单词比不流行的首字母缩略词和缩写词要好。使用缩写的唯一原因是节省打字时间,但现代编辑器具有自动完成功能,因此您只需输入一次全名。缩写之所以有问题,是因为它们通常只在特定领域使用。在上面的代码中,knn 指的是“K-”,df 指的是“”——一种无处不在的数据结构。如果另一个不熟悉这些缩写的程序员正在阅读代码,他或她会感到困惑。
此函数名称还有另外两个小问题:“get”一词无关紧要。对于大多数命名良好的函数,很明显该函数会返回某些内容,并且名称反映了这一点。这也是不必要的。如果参数名称不够清晰,函数的文档或类型注释将描述参数类型。
那么我们如何重命名这个函数呢?例如:
def k_nearest_neighbors(dataframe):
现在即使是外行也知道这个函数在计算什么,并且参数的名称()也清楚地告诉我们应该传递什么类型的参数。
单一功能原则
“单一职责原则”来自“Bob 大叔”的一本书,它不仅适用于类和模块,也适用于函数(最初的目标)。该原则强调函数应该具有“单一功能”。也就是说,一个函数应该只做一件事。这样做的一个重要原因是:如果每个函数只做一件事,那么只有当函数做这件事的方式必须改变时,才需要更改该函数。当一个函数可以删除时,事情就变得简单了:如果其他地方发生了变化,并且不再需要该函数的单一功能,只需将其删除即可。
让我们举个例子来解释一下。下面是一个可以做多件事的函数:
def calculate_and print_stats(list_of_numbers):
sum = sum(list_of_numbers)
mean = statistics.mean(list_of_numbers)
median = statistics.median(list_of_numbers)
mode = statistics.mode(list_of_numbers)
print('-----------------Stats-----------------')
print('SUM: {}'.format(sum) print('MEAN: {}'.format(mean)
print('MEDIAN: {}'.format(median)
print('MODE: {}'.format(mode)
此函数执行两项操作:计算一组关于数字列表的统计数据,并将它们打印到 。此函数违反了函数更改只能有一个原因的原则。此函数可能更改的原因有两个:需要计算新的或不同的数据,或者需要更改输出格式。此函数最好写成两个单独的函数:一个执行计算并返回结果,另一个接收结果并打印。具有多种用途的函数的一个致命漏洞是在函数名称中包含单词“and”。
这种分离还简化了函数行为的测试,它们不只是被分成一个模块中的两个函数,还可以在适当的情况下存在于不同的模块中。这使得测试更清晰,更易于维护。
一个函数只做两件事的情况非常少见。一个函数负责很多很多任务的情况更常见。再次强调,为了可读性和可测试性,我们应该将这些“通用”函数拆分成小函数,每个函数只负责一项任务。
文档注释
很多开发者都知道 PEP-8,它定义了编程的风格指南,但很少有人知道 PEP-257,它定义了文档注释的风格。这里我就不详细介绍 PEP-257 了,读者可以详细阅读指南中约定的文档注释风格。
首先,文档注释是模块、函数、类或方法定义中的第一个字符串声明,这个字符串应该清晰的描述函数的作用、输入参数和返回参数,PEP-257 的主要信息如下:
在编写函数时遵循这些规则很容易。我们只需要养成编写文档注释的习惯,并在实际编写函数体之前完成它们。如果你不能清楚地描述这个函数的作用,那么你需要多想想你为什么要写这个函数。
返回值
函数可以而且应该被认为是独立的小程序,它们以参数的形式接受一些输入,并返回一些输出值。当然,参数是可选的,但从内部机制来看,返回值不是可选的。即使你尝试创建一个不返回值的函数,我们也不能选择在内部不接受返回值,因为解释器会强制返回一个 None 。不相信的读者可以用下面的代码来测试一下:
❯ python3
Python 3.7.0 (default, Jul 23 2018, 20:22:55)
[Clang 9.1.0 (clang-902.0.39.2)] on darwin
Type "help", "copyright", "credits" or "license" *for *more information.
>>> def add(a, b):
... print(a + b)
...
>>> b = add(1, 2)
3
>>> b
>>> b is None
True
如果你运行上面的代码,你会看到 b 的值确实是 None。所以即使我们编写一个没有语句的函数,它仍然会返回一些内容。但是函数应该返回一些内容,因为它也是一个小程序。一个没有输出的程序有多大用处,我们如何测试它?
我甚至想做出以下声明:每个函数都应该返回一个有用的值,即使这个值仅对测试有用。我们编写的代码应该需要测试,没有返回值的函数很难测试正确性。上面的函数可能需要重定向 I/O 才能进行测试。此外,返回值可以改变方法调用。以下代码展示了这个概念:
with open('foo.txt', 'r') as input_file:
for line in input_file:
if line.strip().lower().endswith('cat'):
# ... do something useful with these lines
行 if line.strip().lower().('cat') 工作正常,因为字符串方法(strip()、lower()、())返回一个字符串作为调用该函数的结果。
当人们被问及为什么他们编写的函数不返回值时,他们会给出以下一些常见原因:
“执行类似 I/O 操作的函数(例如将值保存到数据库)无法返回有用的输出。”
我不同意这种观点,因为当操作成功完成时该函数可以返回 True。
“我需要返回多个值,因为只返回一个值没有任何意义。”
当然,你也可以返回一个包含多个值的元组。简而言之,即使在现有代码库中,从函数返回一个值也绝对是一个好主意,而且不太可能破坏任何东西。
函数长度
函数长度直接影响可读性,进而影响可维护性。因此,请确保函数足够短。对我来说,50 行函数是合理的长度。
如果函数遵循单一职责原则,它通常会很短。如果函数是纯函数或幂等函数(下面讨论),它也会很短。这些想法对于创建简洁的代码非常有帮助。
那么如果函数太长该怎么办?重构!代码重构是您在编写代码时可能经常会做的事情,即使您不熟悉这个术语。它意味着在不改变程序行为的情况下更改程序的结构。因此,从长函数中取出几行代码并将其转换为属于该函数的函数也是一种代码重构。这也是缩短长函数的最快和最常见的方法。只要您适当地命名这些新函数,它就会使代码更易于阅读。
幂等性和函数纯度
幂等函数 ( ) 在给定同一组变量参数时返回相同的值,无论调用多少次。该函数的结果不依赖于非局部变量、其参数的易变性或来自任何 I/O 流的数据。以下 () 函数是幂等的:
def add_three(number):
"""Return *number* + 3."""
return number + 3
每当调用(7)时,其返回值为 10。以下是一个非幂等函数的示例:
def add_three():
"""Return 3 + the number entered by the user."""
number = int(input('Enter a number: '))
return number + 3
此函数不是幂等函数,因为函数的返回值取决于 I/O,即用户输入的数字。每次调用此函数时,它都可能返回不同的值。如果调用两次,用户可能第一次输入 3,第二次输入 7,导致对 () 的调用分别返回 6 和 10。
为什么幂等性很重要?
可测试性和可维护性。幂等函数易于测试,因为它们在给定相同参数时会返回相同的结果。测试就是检查对函数的不同调用是否返回预期值。此外,幂等函数测试速度快,这在单元测试中非常重要,但经常被忽视。幂等函数也易于重构。无论您如何更改函数外部的代码,使用相同参数调用函数返回的值都是相同的。
什么是“纯”函数?
在函数式编程中,如果函数是幂等的并且没有明显的副作用,则该函数是纯函数。请记住,幂等函数意味着给定一组参数,该函数始终返回相同的结果,并且不能使用任何外部因素来计算结果。但是,这并不意味着幂等函数不能影响非局部变量或 I/O 等。例如,如果上面的 () 的幂等版本在返回结果之前打印结果,它仍然是幂等的,因为它访问了 I/O,这不会影响函数的返回值。调用 print() 是一种副作用:与返回值以外的程序或系统的其余部分进行交互。
我们来扩展一下 () 的例子。我们可以使用下面的代码片段来查看 () 函数被调用了多少次:
add_three_calls = 0
def add_three(number):
"""Return *number* + 3."""
global add_three_calls
print(f'Returning {number + 3}')
add_three_calls += 1
return number + 3
def num_calls():
"""Return the number of times *add_three* was called."""
return add_three_calls
现在我们将结果打印到控制台(一个副作用)并修改非局部变量(另一个副作用),但由于这些副作用不会影响函数的返回值,因此该函数仍然是幂等的。
纯函数没有副作用。它不仅不使用任何“外部数据”来计算值,而且除了计算和返回值之外,它不会与系统/程序的其他部分进行交互。因此,虽然我们新定义的 () 仍然是幂等函数,但它不再是纯函数了。
纯函数不记录语句或 print() 调用,不使用数据库或互联网连接,不访问或修改非局部变量。它们不调用任何其他非纯函数。
总之,纯函数无法产生爱因斯坦所说的“远距离幽灵效应”(在计算机科学背景下)。它们不会以任何方式修改程序或系统的其余部分。它们是命令式编程中最安全的函数(编写代码就是命令式编程)。它们非常容易测试和维护,甚至比纯幂等函数更好。测试纯函数几乎和执行它们一样快。而且测试很简单:无需数据库连接或其他外部资源,无需设置代码,测试后也无需清理。
显然,幂等性和纯函数是可有可无的,但不是必需的。也就是说,我们喜欢编写纯函数或幂等函数,因为它们具有上述优点,但我们不能一直这样写。关键在于,当我们开始部署代码时,我们会本能地想到要去除副作用和外部依赖。这使得我们编写的每一行代码都更容易测试,即使我们不编写纯函数或幂等函数。
总结
编写良好函数的秘诀已不再是秘密。只需遵循一些公认的最佳实践和经验法则即可。希望本文能有所帮助。
原文链接:
扫一扫在手机端查看
我们凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求,请立即点击咨询我们或拨打咨询热线: 13761152229,我们会详细为你一一解答你心中的疑难。