在机器学习中,咱们常常须要使用类和函数定义模型的各个部分,例如定义读取数据的函数、预处理数据的函数、模型架构和训练过程的函数等等。那么什么样的函数才是漂亮的、赏心悦目的代码呢?在本文中,Jeff Knupp 从命名到代码量等六方面探讨了如何养成美妙的函数。html
与多数现代编程语言同样,在 Python 中,函数是抽象和封装的基本方法之一。你在开发阶段或许已经写过数百个函数,但并不是每一个函数都生而平等。写出「糟糕的」函数会直接影响代码的可读性和可维护性。那么,什么样的函数是「糟糕的」函数呢?更重要的是,要怎么写出「好的」函数呢?python
数学中充满了函数,尽管咱们可能记不住它们。首先来回忆一下你们最喜欢的话题——微积分。你可能记得这个方程式: f(x) = 2x + 3. 这是一个叫作「f」的函数,含有一个未知数 x,「返回」2*x+3。这个函数可能和咱们在 Python 中看到的不同,但它的基本思想和计算机语言中的函数是同样的。数据库
函数在数学中历史悠久,但在计算机科学中更加神通广大。尽管如此,函数仍是存在一些缺陷。接下来咱们将讨论一下什么是「好的」函数,以及在出现什么样的征兆时咱们须要重构函数。编程
好的 Python 函数与蹩脚 Python 函数的区别是什么?「好」函数的定义之多让人惊讶。从咱们的目的出发,我会把好的 Python 函数定义为符合如下清单中大部分规则的函数(有些比较难实现):小程序
对不少人来讲,这个列表可能有些过于严格。但我保证,若是你的函数符合这些规则,你的代码看起来会很是漂亮。下面我将分步讲解各个规则,而后总结这些规则如何构成一个「好」函数。缓存
关于这个问题,我最喜欢的一句话(出自 Phil Karlton,总被误觉得是 Donald Knuth 说的)是:安全
在计算机科学中只有两个难题:缓存失效和命名问题。数据结构
听起来有点匪夷所思,但整个不错的命名真的很难。下面就有一个糟糕的函数命名:架构
def get_knn(from_df):
我基本上在任何地方都见过糟糕的命名,但这个例子来自数据科学(或者说,机器学习),从业者老是在 Jupyter notebook 上写代码,而后尝试将那些不一样的单元变成一个可理解的程序。机器学习
该函数命名的第一个问题是使用首字母缩写/缩略词。比起缩略词和并未普及的首字母缩写,完整的英语单词会更好。使用缩写的惟一缘由是为了节省打字时间,但现代的编辑器都有自动补全功能,因此你只需键入一次全名。之因此说缩写是一个问题,是由于它们一般只能用于特定领域。在上面的代码中,knn 是指「K-Nearest Neighbors」,df 指的是「DataFrame」——无处不在的 Pandas 数据结构。若是另一个不太熟悉这些缩写的编程人员正在阅读代码,那 TA 就会一头雾水。
关于这个函数名称,还有另外两个小问题:单词「get」可有可无。对于大多数命名比较好的函数,很明显函数会返回一些东西,其名字会反映这一点。from_df 也是没必要要的。若是参数的名称描述不够清楚的话,函数的文档注释或者类型注释将描述参数类型。
那咱们如何从新命名这个函数呢?例如:
def k_nearest_neighbors(dataframe):
如今,即便是外行也知道这个函数在计算什么了,参数的名称(dataframe)也清楚地告诉咱们应该传递什么类型的参数。
「单一功能原则」来自 Bob Martin「大叔」的一本书,不只适用于类和模块,也一样适用于函数(Martin 最初的目标)。该原则强调,函数应该具备「单一功能」。也就是说,一个函数应该只作一件事。这么作的一大缘由是:若是每一个函数只作一件事,那么只有在函数作那件事的方式必须改变时,该函数才须要改变。当一个函数能够被删除时,事情就好办了:若是其余地方发生改动,再也不须要该函数的单一功能,那么只需将其删除。
举个例子来解释一下。如下是一个不止作一件「事」的函数:
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)
这一函数作两件事:计算一组关于数字列表的统计数据,并将它们打印到 STDOUT。该函数违反了只有一个缘由能让函数改变的原则。显然有两个缘由可让该函数作出改变:新的或不一样的数据须要计算或输出的格式须要改变。最好将该函数写成两个独立的函数:一个用来执行并返回计算结果;另外一个用来接收结果并将其打印出来。函数有多重功能的一个致命漏洞是函数名称中含有单词「and」
这种分离还能够简化针对函数行为的测试,并且它们不只被分离成一个模块中的两个函数,还可能在适当状况下存在于不一样的模块中。这使得测试更加清洁、维护更加简单。
只作两件事的函数其实很是罕见。更常见的状况是一个函数负责许多许多任务。再次强调一下,为可读性、可测试性起见,咱们应该将这些「多面手」函数分红一个一个的小函数,每一个小函数只负责一项任务。
不少 Python 开发者都知道 PEP-8,它定义了 Python 编程的风格指南,但不多有人了解定义了文档注释风格的 PEP-257。在这里并不会详细介绍 PEP-257,读者可详细阅读该指南所约定的文档注释风格。
首先文档注释是在定义模块、函数、类或方法的第一段字符串声明,这一段字符串应该须要描述清楚函数的做用、输入参数和返回参数等。PEP-257 的主要信息以下:
在编写函数时,遵循这些规则很容易。咱们只须要养成编写文档注释的习惯,并在实际写函数主体以前完成它们。若是你不能清晰地描述这个函数的做用是什么,那么你须要更多地考虑为何要写这个函数。
函数能够且应该被视为一个独立的小程序。它们以参数的形式获取一些输入,并返回一些输出值。固然,参数是可选的,可是从 Python 内部机制来看,返回值是不可选的。即便你尝试建立一个不会返回值的函数,咱们也不能选择不在内部采用返回值,由于 Python 的解释器会强制返回一个 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。因此即便咱们编写一个不包含 return 语句的函数,它仍然会返回某些东西。不过函数也应该要返回一些东西,由于它也是一个小程序。没有输出的程序又会有多少用,咱们又如何测试它呢?
我甚至但愿发表如下声明:每个函数都应该返回一个有用的值,即便这个值仅可用来测试。咱们写的代码应该须要获得测试,而不带返回值的函数很难测试它的正确性,上面的函数可能须要重定向 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().endswith('cat') 可以正常运行,由于字符串方法 (strip(), lower(), endswith()) 会返回一个字符串以做为调用函数的结果。
如下是人们在被问及为何他们写的函数没有返回值时给出的一些常见缘由:
「函数所作的就是相似 I/O 的操做,例如将一个值保存到数据库中,这种函数不能返回有用的输出。」
我并不一样意这种观点,由于在操做成功完成时,函数能够返回 True。
「我须要返回多个值,由于只返回一个值并不能表明什么。」
固然也能够返回包含多个值的一个元组。简而言之,即便在现有的代码库中,从函数返回一个值确定是一个好主意,而且不太可能破坏任何东西。
函数的长度直接影响了可读性,于是会影响可维护性。所以要保证你的函数长度足够短。50 行的函数对我而言是个合理的长度。
若是函数遵循单一功能原则,通常而言其长度会很是短。若是函数是纯函数或幂等函数(下面会讨论),它的长度也会较短。这些想法对于构造简洁的代码颇有帮助。
那么若是一个函数太长该怎么办?代码重构(refactor)!代码重构极可能是你写代码时一直在作的事情,即便你对这个术语并不熟悉。它的含义是:在不改变程序行为的前提下改变程序的结构。所以从一个长函数提取几行代码并转换为属于该函数的函数也是一种代码重构。这也是将长函数缩短最快和最经常使用的方法。只要适当给这些新函数命名,代码的阅读将变得更加容易。
幂等函数(idempotent function)在给定相同变量参数集时会返回相同的值,不管它被调用多少次。函数的结果不依赖于非局部变量、参数的易变性或来自任何 I/O 流的数据。如下的 add_three(number) 函数是幂等的:
def add_three(number): """Return _number_ \+ 3.""" return number + 3
不管什么时候调用 add_three(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,使得对 add_three() 的调用分别返回 6 和 10。
可测试性和可维护性。幂等函数易于测试,由于它们在使用相同参数的状况下会返回一样的结果。测试就是检查对函数的不一样调用所返回的值是否符合预期。此外,对幂等函数的测试很快,这在单元测试(Unit Testing)中很是重要,但常常被忽视。重构幂等函数也很简单。无论你如何改变函数之外的代码,使用一样的参数调用函数所返回的值都是同样的。
在函数编程中,若是函数是幂等函数且没有明显的反作用(side effect),则它就是纯函数。记住,幂等函数表示在给定参数集的状况下该函数老是返回相同的结果,不能使用任何外部因素来计算结果。可是,这并不意味着幂等函数没法影响非局部变量(non-local variable)或 I/O stream 等。例如,若是上文中 add_three(number) 的幂等版本在返回结果以前先输出告终果,它仍然是幂等的,由于它访问了 I/O stream,这不会影响函数的返回值。调用 print() 是反作用:除返回值之外,与程序或系统中其他部分的交互。
咱们来扩展一下 addthree(number) 这个例子。咱们能够用如下代码片断来查看 addthree(number) 函数被调用的次数:
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
如今咱们向控制台输出结果(一项反作用),并修改了非局部变量(又一项反作用),可是因为这些反作用不影响函数的返回值,所以该函数仍然是幂等的。
纯函数没有反作用。它不只不使用任何「外来数据」来计算值,也不与系统/程序的其它部分进行交互,除了计算和返回值。所以,尽管咱们新定义的 add_three(number) 还是幂等函数,但它再也不是纯函数。
纯函数不记录语句或 print() 调用,不使用数据库或互联网链接,不访问或修改非局部变量。它们不调用任何其它的非纯函数。
总之,纯函数没法(在计算机科学背景中)作到爱因斯坦所说的「幽灵般的远距效应」(spooky action at a distance)。它们不以任何形式修改程序或系统的其他部分。在命令式编程中(写 Python 代码就是命令式编程),它们是最安全的函数。它们很是好测试和维护,甚至在这方面优于纯粹的幂等函数。测试纯函数的速度与执行速度几乎同样快。并且测试很简单:没有数据库链接或其它外部资源,不要求设置代码,测试结束后也不须要清理什么。
显然,幂等和纯函数是锦上添花,但并不是必需。即,因为上述优势,咱们喜欢写纯函数或幂等函数,但并非全部时候均可以写出它们。关键在于,咱们本能地在开始部署代码的时候就想着剔除反作用和外部依赖。这使得咱们所写的每一行代码都更容易测试,即便并无写纯函数或幂等函数。
写出好的函数的奥秘再也不是秘密。只需按照一些完备的最佳实践和经验法则。但愿这篇文章可以帮助到你们。
相关推荐:Python学习手册