机器之心合集
在机器学习中,我们经常需要使用类和函数来定义模型的各个部分,比如定义读取数据的函数、预处理数据的函数、模型架构和训练过程的函数等等。那么什么样的函数才是漂亮的并且赏心悦目?在这篇文章中,Jeff 从命名和代码大小等六个方面讨论了如何开发精彩的功能。
与大多数现代编程语言一样,函数是 中抽象和封装的基本方法之一。您可能在开发阶段编写了数百个函数,但并非每个函数都是一样的。写“不好”的函数会直接影响代码的可读性和可维护性。那么,什么样的函数是“坏”函数呢?更重要的是,如何编写一个“好的”函数?
简要回顾
数学充满了函数,尽管我们可能不记得它们。首先,我们回顾一下大家最喜欢的话题——微积分。您可能还记得这个方程:f(x) = 2x + 3。这是一个名为“f”的函数,它接受未知数 x 并“返回”2*x+3。这个函数可能和我们看到的有所不同,但是基本思想和计算机语言中的函数是一样的。
函数在数学中有着悠久的历史,但它们在计算机科学中的作用更为强大。尽管如此,该功能仍然存在一些缺陷。接下来我们将讨论什么是“好”函数以及我们需要重构它的标志。
决定功能好坏的关键
好的函数和蹩脚的函数有什么区别? “好”函数的定义之多令人惊讶。出于我们的目的,我将一个好的函数定义为符合以下列表中的大多数规则的函数(有些规则更难实现):
对于许多人来说,这个列表可能有点过于严格。但我保证,如果你的函数遵循这些规则,你的代码将会看起来很漂亮。下面我将一步步解释每条规则,然后总结这些规则是如何构成一个“好的”函数的。
姓名
关于这个问题,我最喜欢的一句话(来自菲尔,总是被误认为是这么说的)是:
计算机科学中只有两个难题:缓存失效和命名问题。
听起来有点奇怪,但整个良好的命名确实很困难。这是一个糟糕的函数命名的例子:
def get_knn(from_df):
我基本上到处都看到了糟糕的命名,但这个例子来自数据科学(或者更确切地说,机器学习),其中从业者总是在彼此之上编写代码,然后尝试将这些不同的单元转换为可理解的程序。
命名此函数的第一个问题是首字母缩写词/缩写词的使用。完整的英文单词比缩写和不流行的首字母缩略词更好。使用缩写的唯一原因是节省打字时间,但现代编辑器具有自动完成功能,因此您只需输入全名一次。缩写之所以成为一个问题,是因为它们通常只在特定领域使用。上面的代码中,knn 指的是“K-”,df 指的是“”——普遍存在的数据结构。如果另一个不熟悉这些缩写的程序员正在阅读代码,他们会感到困惑。
该函数名称还有另外两个小问题:“get”一词并不重要。对于大多数命名良好的函数,很明显该函数会返回一些内容,并且其名称将反映这一点。亦无必要。如果参数名称不够清晰,函数的文档注释或类型注释会描述参数类型。
那么我们如何重命名这个函数呢?例如:
def k_nearest_neighbors(dataframe):
现在,即使是外行人也知道这个函数在计算什么,并且参数名称 () 清楚地告诉我们应该传递什么类型的参数。
单一功能原则
“单一函数原则”来自鲍勃“叔叔”的一本书,不仅适用于类和模块,也适用于函数(最初的目标)。该原则强调函数应该具有“单一功能”。也就是说,一个函数应该只做一件事。这样做的一个重要原因是:如果每个函数只做一件事,那么只有当函数做那件事的方式必须改变时才需要改变。当一个函数可以被删除时,事情会变得更容易:如果其他地方发生了变化并且不再需要该函数的单一功能,只需删除它即可。
让我用一个例子来解释一下。这是一个执行多个“事情”的函数:
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.().().('cat') 行工作正常,因为字符串方法 (()、()、()) 返回一个字符串作为调用该函数的结果。
当被问及为什么编写不返回值的函数时,人们给出的一些常见原因如下:
“函数所做的只是类似 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。
为什么幂等性很重要?
可测试性和可维护性。幂等函数很容易测试,因为它们在给定相同参数的情况下返回相同的结果。测试是检查函数的不同调用返回的值是否符合预期。此外,测试幂等函数速度很快,这在单元测试(Unit)中非常重要,但经常被忽视。重构幂等函数也很容易。无论函数外部的代码如何更改,使用相同参数调用函数返回的值都是相同的。
什么是“纯”函数?
在函数式编程中,如果一个函数是幂等的并且没有明显的副作用,那么它就是纯函数。请记住,幂等函数意味着在给定参数集的情况下,该函数始终返回相同的结果,并且不能使用外部因素来计算结果。然而,这并不意味着幂等函数不能影响非局部变量(非)或 I/O 等。例如,如果上面 () 的幂等版本在返回结果之前打印结果,它仍然是幂等的,因为它访问I/O,不影响函数的返回值。 () 是一个副作用:除了返回值之外,还与程序或系统的其余部分进行交互。
让我们扩展 () 示例。我们可以使用下面的代码片段来查看 () 函数被调用了多少次:
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
现在我们将结果打印到控制台(副作用)并修改非局部变量(另一个副作用),但由于这些副作用不会影响函数的返回值,因此该函数仍然是幂等的。
纯函数没有副作用。它不仅不使用任何“外部数据”来计算值,而且除了计算和返回值之外,它也不与系统/程序的其余部分交互。所以,虽然我们新定义的()仍然是幂等的,但它不再是一个纯函数了。
纯函数不记录语句或 () 调用,不使用数据库或 连接,也不访问或修改非局部变量。他们不调用任何其他不纯的函数。
简而言之,纯函数(在计算机科学背景下)无法实现爱因斯坦所说的“远距离幽灵效应”(a)。他们不会以任何方式修改程序或系统的其余部分。在命令式编程中(编写代码就是命令式编程),它们是最安全的函数。它们非常容易测试和维护,在这方面甚至比纯粹的幂等函数更好。测试纯函数几乎与执行一样快。而且测试很简单:没有数据库连接或其他外部资源,不需要设置代码,测试后也不需要清理。
显然,幂等函数和纯函数很好,但不是必需的。就是因为上面的优点,我们喜欢写纯函数或者幂等函数,但也不可能一直写。关键是,当我们开始部署代码时,我们的本能是消除副作用和外部依赖。这使得我们编写的每一行代码都更容易测试,即使我们不编写纯函数或幂等函数。
总结
编写好的函数的秘密不再是秘密。只需遵循一些合理的最佳实践和经验法则即可。希望这篇文章能够对大家有所帮助。
原文链接: