瞧瞧,这样的「函数」才叫 Pythonic

2023-11-04

在机器学习中,我们经常需要使用类和函数定义模型的各个部分,例如定义读取数据的函数、预处理数据的函数、模型架构和训练过程的函数等等。那么什么样的函数才是漂亮的、赏心悦目的代码呢?本期教程,会从命名到代码量等六方面探讨如何养成美妙的函数。文末有给大家录的视频教程,大家可以按需学习,不清楚的地方也可以留言!

 

与多数现代编程语言一样,在 Python 中,函数是抽象和封装的基本方法之一。你在开发阶段或许已经写过数百个函数,但并非每个函数都生而平等。写出「糟糕的」函数会直接影响代码的可读性和可维护性。那么,什么样的函数是「糟糕的」函数呢?更重要的是,要怎么写出「好的」函数呢?

 

简单回顾

 

数学中充满了函数,尽管我们可能记不住它们。首先来回忆一下大家最喜欢的话题——微积分。你可能记得这个方程式:f(x) = 2x + 3. 这是一个叫做「f」的函数,含有一个未知数 x,「返回」2*x+3。这个函数可能和我们在 Python 中看到的不一样,但它的基本思想和计算机语言中的函数是一样的。

 

函数在数学中历史悠久,但在计算机科学中更加神通广大。尽管如此,函数还是存在一些缺陷。接下来我们将讨论一下什么是「好的」函数,以及在出现什么样的征兆时我们需要重构函数。

 

决定函数好坏的关键

 

好的 Python 函数与蹩脚 Python 函数的区别是什么?「好」函数的定义之多让人惊讶。从我们的目的出发,我会把好的 Python 函数定义为符合以下清单中大部分规则的函数(有些比较难实现):

 

  • 命名合理
  • 具有单一功能
  • 包含文档注释
  • 返回一个值
  • 代码不超过 50 行
  • 幂等,尽可能是纯函数

 

对很多人来说,这个列表可能有些过于严格。但我保证,如果你的函数符合这些规则,你的代码看起来会非常漂亮。下面我将分步讲解各个规则,然后总结这些规则如何构成一个「好」函数。

 

命名

 

关于这个问题,我最喜欢的一句话(出自 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-8:https://www.python.org/dev/peps/pep-0008/
  • PEP-257:https://www.python.org/dev/peps/pep-0257/

 

首先文档注释是在定义模块、函数、类或方法的第一段字符串声明,这一段字符串应该需要描述清楚函数的作用、输入参数和返回参数等。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() 是副作用:除返回值以外,与程序或系统中其余部分的交互。

 

我们来扩展一下 add_three(number) 这个例子。我们可以用以下代码片段来查看 add_three(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 代码就是命令式编程),它们是最安全的函数。它们非常好测试和维护,甚至在这方面优于纯粹的幂等函数。测试纯函数的速度与执行速度几乎一样快。而且测试很简单:没有数据库连接或其它外部资源,不要求设置代码,测试结束后也不需要清理什么。

 

显然,幂等和纯函数是锦上添花,但并非必需。即,由于上述优点,我们喜欢写纯函数或幂等函数,但并不是所有时候都可以写出它们。关键在于,我们本能地在开始部署代码的时候就想着剔除副作用和外部依赖。这使得我们所写的每一行代码都更容易测试,即使并没有写纯函数或幂等函数。

 

总结

 

写出好的函数的奥秘不再是秘密。只需按照一些完备的最佳实践和经验法则。希望本期教程能够帮助到大家。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

瞧瞧,这样的「函数」才叫 Pythonic 的相关文章

随机推荐

  • 【入侵检测】5.27quiz

    入侵检测 5 27 课上quiz1 3 今天两门课上一共发了4个quiz 属实难顶 quiz1 quiz1是对XTP文件的信息内容进行读取查找 找到相应的flag信息 题目 请输入Linux XTP文件中包含的Flag信息 直接将文件 li
  • 在 Windows 下搭建 Appium + Android 自动化测试环境

    前言 本来并不打算写这么一篇文章 但是实践下来发现网上的各种教程里大致有两个问题 一是文章有些跟不上时代 目前android开发和测试的技术更新都比较快 内容有些过期 二是细节部分不是太完整 拼拼凑凑也能完成 但对新手来说就比较痛苦 那么
  • 【带限制的完全背包】Educational Codeforces Round 133 (Rated for Div. 2) D. Chip Move

    题意 给定 n n n 和 k k k 初始步长为 k k k 每次可以走
  • 逆向python生成的可执行文件

    先安装pyinstaller pip install pyinstaller i https pypi douban com simpl 写一个简单的脚本 print hello world pyinstaller基本用法 常用的可选参数如
  • (纯干货,看完不懂你找我)Python+Pytest+Allure+Git+Jenkins接口自动化框架

    Python Pytest Allure Git Jenkins接口自动化框架 一 接口基础 二 项目说明 四 项目功能 五 代码设计与功能说明 六 后期优化 七 感想 一 接口基础 接口测试是对系统和组件之间的接口进行测试 主要是效验数据
  • 【图像去模糊】Deep Multi-scale Convolutional Neural Network for Dynamic Scene Deblurring论文笔记

    一 论文概述 一般因动态场景造成的非均匀模糊是图像去模糊中一个具有挑战性的问题 这类模糊由相机抖动 场景深度以及多个对象运动造成 消除这类复杂运动模糊 传统的基于简单假设的方法不在适用 在本文中 作者提出了一种多尺度卷积神经网络 以端到端的
  • 感知机模型

    目录 一 感知机模型 1 1定义 感知机 1 2几何解释 二 损失函数 三 学习算法 3 1感知机学习算法的原始形式 3 2感知机学习算法的对偶形式 一 感知机模型 1 1定义 感知机 假设输入空间 特征空间 是 输出空间是 输入表示实例的
  • 【前端性能】常见前端性能优化

    常见性能优化 前言 一 图片优化 1 雪碧图 图片精灵 2 图片压缩 3 字体图标代替图片 4 webp图片 二 DOM优化 1 缓存DOM节点查找的结果 2 防抖和节流 3 事件代理 4 减少合并DOM操作 5 DOM读写分离 6 DOM
  • VMware虚拟机扩容——Ubuntu的/dev/sda1分区挂在根目录(“/“)下用LVM(逻辑卷管理)模式扩容无效

    一 问题描述 在VMware虚拟机装了Ubuntu18 04 一开始分配的内存是20多个G 如下图 用了一段时间后发现不够用了 需要扩容 然后上网查了一下虚拟机扩容的方法 但是大多数教程的情况是 根目录 挂在类似 dev xxx xxxro
  • ThinkpadE480 win10改win7

    Thinkpad E480win10改win7 电脑配置信息 i7 8550u 直接进PE 一键装机 选择win7镜像安装到系统盘 安装后进系统失败 没有修改bios信息 win10系统改win7设置bios方法图文教程 一起来围观吧 ht
  • Vue3导出excel,使用js-table2excel

    装依赖 npm install js table2excel vue中引入 import table2excel from js table2excel 使用 const column title sn key sn type text t
  • Mysql命令及增删改查操作

    测试工程师的目的是找出软件的不足 并告诉开发工程师 出现问题的环境 操作步骤和输入输出数据 优秀的测试工程师 需要告诉开发团队 软件的不足 这类不足会导致什么情况 如何避免 以及如何去修改 这是为什么高级软件测试工程师比开发工程师工资高的原
  • Qt应用开发(基础篇)——组合框容器 QGroupBox

    一 前言 QGroupBox继承于QWidget 是一个带有标题的组合框架容器控件 QGroupBox组合框容器自带一个顶部标题 一个面板 面板内部展示各种各样的部件 标题用来解释这些部件为什么集合在一起 并且支持键盘快捷方式切换部件焦点
  • c++读文件(一次全读/每行读/多次读)

    我以为这些都很容易在网上找到 谁知网上乱七八糟的东西太多 让我找了很久 开发环境为Windows VS2013 一次全读 std ifstream t path 读文件ifstream 写文件ofstream 可读可写fstream std
  • 开源IOT——一个最小的物联网系统设计方案及源码

    或许这个可以当成是你的毕业设计又或者你可以用它来控制你想控制的东西 总之你可以用它来做一个最小的物联网系统 不过 在这里可能没有那么复杂的功能 因为强调的是最小 BareMinimum 这也是为什么我没有改Arduino上面的工程名的原因
  • [附源码]计算机毕业设计校园疫情管理系统Springboot程序

    项目运行 环境配置 Jdk1 8 Tomcat7 0 Mysql HBuilderX Webstorm也行 Eclispe IntelliJ IDEA Eclispe MyEclispe Sts都支持 项目技术 SSM mybatis Ma
  • SpringMVC SpringBoot Get请求接收复杂参数

    前沿 对于复杂的接口请求 一般都用POST JSON数据的方式 后端用 RequestBody接收 但是对于某些有洁癖的人或者想严格遵循类Restful风格的人来讲 查询数据就是想用GET 怎么实现呢 Request Data public
  • can总线的示波器检测方法

    整理自网络 stm32的can总线是在APB1上的 stm32f10x的主频是72Mhz can外设时钟是36Mhz stm32f2xx的主频是120Mhz can外设时钟是30Mhz STM32 APB1和APB2区别 APB2负责AD
  • Linux统计某文件夹下文件、文件夹的个数

    http blog sina com cn s blog 464f6dba01012vwv html 统计某文件夹下文件的个数 ls l grep wc l 统计某文件夹下目录的个数 ls l grep wc l 统计文件夹下文件的个数 包
  • 瞧瞧,这样的「函数」才叫 Pythonic

    在机器学习中 我们经常需要使用类和函数定义模型的各个部分 例如定义读取数据的函数 预处理数据的函数 模型架构和训练过程的函数等等 那么什么样的函数才是漂亮的 赏心悦目的代码呢 本期教程 会从命名到代码量等六方面探讨如何养成美妙的函数 文末有