Skip to content
This page has been auto-translated and may contain errors.View in English

函数 ​

随着程序规模的增长,你会发现同一段逻辑要在多处重复编写。函数让你把逻辑写一次、起个名字,然后随处调用。修改的时候只需要改一处,每个调用点都会自动获得修复。

函数是代码复用和抽象的主要方式。它们把某种行为封装起来,给它起个名字,定义清晰的接口(参数和返回值),让它可以在任何地方被调用。命名良好的函数本身就是一种文档:validate_email() 光看名字就能知道这段代码在做什么,不需要读函数体。

在 Python 中,函数是一等对象:它是一个可以像其他值一样传递的值,可以赋值给变量、存进列表或字典、作为参数传给另一个函数,也可以作为返回值。def 会创建这个对象,并把它绑定到当前作用域(也就是这个名字可见的那部分代码范围)中的一个名字上。把函数当作值来对待,正是 sorted(key=...) 这类用法以及小型插件表得以实现的原因,值得你早点熟悉起来。

python
def greet(name):
    return f"Hello, {name}!"

print(greet("小明"))   # "Hello, 小明!"
print(greet("小红"))     # "Hello, 小红!"

写一次,到处用,改一处就够了。

定义函数 ​

def 关键字用来开始一个函数定义,后面跟着函数名、括号、冒号和缩进的函数体。函数在被调用之前什么都不会做。用 def 定义它,再用 () 按名字调用它。

def 是一条语句,它创建一个函数对象,并把它绑定到当前作用域中给定的名字上。函数体在定义时不会执行,只有在函数被调用时才会运行。没有 return 语句的函数会隐式返回 None。

def 会构建一个函数对象,并把它绑定到当前作用域中的这个名字上。Python 读到 def 的时候函数体并不会运行,只有在你调用这个函数时它才会运行。这个时间差在实际开发中很关键:函数体里的一个拼写错误(比如引用了一个不存在的名字)在函数被真正调用之前会一直保持沉默,所以一个从来没被调用过的函数,可能藏着一个 NameError,直到有一天有人调用它,才会在生产环境里暴露出来。

python
def say_hello():
    print("Hello!")

say_hello()   # 调用这个函数
Juno定义函数 写下 def name():,然后把函数体缩进写在下面。在你用 name() 调用它之前什么都不会发生,def 只是把它准备好而已。如果你忘了写 return,函数会默默地返回 None,我早期就因为这个栽过不止一次。
Juno定义函数def 创建函数并绑定到一个名字上,但函数体只有在你调用它时才会运行。没有 return 就意味着函数返回 None。当某个结果莫名其妙地变成空值时,记住这一点会有帮助。
Juno定义函数 函数体不会在 def 执行时运行,只有在你调用这个函数时才会运行,所以里面一个写错的名字可能一直悄无声息,直到真的有代码调用它才暴露出来。没有 return 的函数会返回 None,这本身也是一个安静的坑,尤其是调用方期待拿到一个值的时候。

参数与实参 ​

参数是你的函数所期望的输入。把它们列在括号里。调用函数时,你传入的值会按顺序对应到这些参数上。

参数定义了函数的接口。实参是调用时传入的具体值。位置实参按位置匹配;关键字实参按名字匹配。默认值可以让参数变成可选的。

在调用时,位置实参从左到右依次绑定,关键字实参按名字绑定。如果同一个参数被两种方式同时传入,或者 Python 无法确定该放在哪里,就会在调用处而不是函数体内部抛出 TypeError。Python 还允许你限制函数签名:单独的 * 会让它后面的所有参数变成仅限关键字传参(调用方必须写出参数名),而 / 会让它前面的所有参数变成仅限位置传参。在布尔标志和参数较多的签名上,尽量用仅限关键字传参,因为一个位置传参的 True 一旦放错位置,是那种能顺利跑起来却悄悄埋下 bug 的错误。

python
def greet(name, greeting):
    print(f"{greeting}, {name}!")

greet("小明", "Hello")    # "Hello, 小明!"
greet("小红", "Hi")         # "Hi, 小红!"

参数(parameter)是函数定义中的名字。实参(argument)是你调用时实际传入的值。实际使用中大家常常把这两个词混用;这个区分主要在读文档时才用得上。

Juno参数与实参 参数是你列在括号里的名字,实参是你调用时实际传进去的值。Python 会从左到右一一对应,所以你传值的顺序就是它们落位的顺序。
Juno参数与实参 参数是定义里的名字,实参是调用时的值。位置传参按从左到右绑定,所以顺序就是约定。这两个词常被混用,区别只有在读文档时才要紧。
Juno参数与实参 位置实参从左到右绑定,关键字实参按名字绑定,冲突会在调用处直接抛出 TypeError。单独的 * 会强制调用方为它后面的参数写出名字,这对一个原本布尔标志一多就容易在调用处让人瞎猜的函数来说,是低成本的解决办法。

默认值 ​

你可以给参数设置默认值。如果调用方没有提供这个实参,就会使用默认值。带默认值的参数必须写在不带默认值的参数后面。

默认值可以让参数变成可选的。它们只在定义时被求值一次,而不是每次调用都重新计算。这一点对可变默认值尤其重要:def f(items=[]) 会让所有调用共享同一个列表。解决办法是把默认值设为 None,然后在函数体内部再创建列表。

有个容易踩坑的细节:默认值只在 def 执行时构建一次,之后每次用到它的调用都会共享同一个默认值。对于像 0 或 "Hello" 这样的不可变默认值(也就是不能就地修改的值)来说没问题。但对于像列表或字典这样的可变默认值(可以就地修改的值)来说就是个陷阱,因为每次落回默认值的调用都在复用同一个对象:

python
def add_item(item, items=[]):   # 这个列表只在此处创建一次,之后一直被共用
    items.append(item)
    return items

add_item("a")   # ['a']
add_item("b")   # ['a', 'b']  <- 并不是一个新的列表

标准解法是:把默认值设为 None,然后在函数体内部构建一个全新的容器。

python
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items
python
def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

greet("小明")           # "Hello, 小明!"
greet("小明", "Hi")     # "Hi, 小明!"

带默认值的参数必须写在不带默认值的参数后面。

Juno默认值 给参数设个默认值,它就变成可选的了,调用方可以跳过它。带默认值的参数必须排在必选参数后面,这个顺序是 Python 强制要求的。这样你就不用每次调用都写同一个值了。
Juno默认值 默认值让参数变成可选的,并且必须排在必选参数后面。要注意的坑是:默认值只在定义时求值一次,所以永远不要用像 [] 这样的可变对象做默认值。把默认值设为 None,再在函数体内部构建列表。
Juno默认值 默认值只在 def 执行时构建一次,并被所有调用共享,所以 items=[] 会在调用之间悄悄累积内容。把默认值设为 None,在函数体内部再新建一个。在实际代码评审中,这个坑出现的频率几乎比其他任何 Python 陷阱都高。

关键字实参 ​

调用函数时,你可以给实参命名。这会让调用更容易读懂,尤其是对参数很多的函数,而且还能让你以任意顺序传值。

关键字实参让函数调用自解释。你可以混用位置实参和关键字实参:位置实参必须写在前面。对于带布尔标志或多个同类型参数的函数,关键字实参能防止因为传参顺序搞错而产生的隐性错误。

关键字实参按名字绑定,所以读起来清楚,也让你不用记住参数的位置。调用处的规则是:位置实参在前,关键字实参在后,给同一个参数命名两次会抛出 TypeError。真正值得做的设计其实是反过来的:在自己的函数签名里放一个单独的 *,强制调用方必须写出参数名(def connect(host, *, timeout=30))。这几乎不花什么代价,却能把一个含义不明的 connect("db", 30) 变成 connect("db", timeout=30),即便日后有人在中间插入新参数,这个调用依然能读懂、能正常工作。

python
def describe_player(name, score, level):
    print(f"{name} | Score: {score} | Level: {level}")

describe_player("小明", 87, 5)                        # 位置传参
describe_player(name="小明", level=5, score=87)       # 关键字传参,顺序随意
describe_player("小明", level=5, score=87)            # 混合:位置在前
Juno关键字实参 在调用时给实参命名(比如 score=87),调用本身就能说明自己在做什么,而且顺序也随意了。唯一的规则是:位置实参必须写在命名实参前面。对参数一大堆的函数特别好用。
Juno关键字实参 给实参命名能让调用自解释、顺序无关紧要,但位置实参必须先写。对于参数很多或带布尔标志的函数,优先用关键字实参,这样没人需要数位置才能看懂调用在做什么。
Juno关键字实参 关键字实参按名字绑定,必须跟在位置实参后面。真正的关键在于你自己的函数签名:单独的 * 会强制调用方必须给之后的参数命名,这样即使日后有人在中间插入新参数,调用代码依然清晰易读。

返回值 ​

return 把一个值送回给调用方。没有 return,函数会返回 None。一旦 return 执行,函数就会立刻退出。它之后的同一代码块里的其他代码都会被跳过。

return 会退出函数,并把一个值传给调用方。没有显式 return 的函数会隐式返回 None。return 可以出现在函数体的任何位置,也可以多次使用;第一个被执行到的 return 就会结束函数。这使得提前返回在守卫语句(guard clause)中非常有用。

return 把一个值交给调用方,并当场结束函数,而一个没有 return 就跑到结尾的函数会返回 None。多个 return 是一种良好的写法,不是代码异味:在开头就为失败情况或边界情况提前 return(即"守卫语句"模式),比把整个函数体嵌套在一个大大的 if 里读起来舒服得多。有一个细节值得提前知道,免得被它惊到:try 块里的 return 仍然会先执行对应的 finally 块,所以即使提前返回,finally 里的清理工作也依然会执行。

python
def add(a, b):
    return a + b

result = add(3, 4)   # result = 7
print(result)

return 也会立刻退出函数。它之后同一代码块里的代码不会执行。

Juno返回值return 把一个值送回给调用这个函数的地方,函数就此停下,之后的代码都会被跳过。不写 return 就会得到 None。print 是把值显示出来,return 是把值交回去让你继续用,这个区别我花了点时间才真正体会到。
Juno返回值return 把一个值传回去并立刻退出,不写 return 就是 None。你可以写多个 return,在开头针对边界情况提前返回,通常比把所有逻辑塞进一个大 if 读起来清爽得多。
Juno返回值return 会当场退出,跑到结尾没有 return 就得到 None。多用提前返回当作守卫语句,而不是层层嵌套。还要记住 try 里的 return 仍然会执行 finally 块,所以即便提前退出,清理逻辑照样会跑。

返回多个值 ​

Python 允许你用逗号分隔的方式返回多个值。调用方会以元组的形式接收它们,并可以在一行代码里把它们拆成不同的名字。

用逗号返回多个值,实际上是把它们打包成一个元组。调用方用匹配的名字来解包。这是 Python 里处理天然会产生多个结果的函数时常用的写法。这不是什么特殊功能,本质就是元组的打包与解包。

return a, b 会把这些值打包成一个元组,调用方用 low, high = f() 把它们拆开。对两三个结果来说这样写很清晰。但超过这个数量,位置就会变成一个隐患:没有人调用这个函数时还记得第三项是计数还是平均值,一旦两个位置调换,就是一个悄无声息的 bug。到了这个程度,应该改成返回带名字的东西,比如 NamedTuple 或 @dataclass,这样调用方用的是 result.average 而不是 result[2]。无论用哪种写法,都把返回值标注成 tuple[int, str],这样类型检查器就能捕捉到调用方解包数量不对的错误。

python
def min_max(numbers):
    return min(numbers), max(numbers)

low, high = min_max([3, 7, 1, 9, 4])
print(low, high)   # 1 9

low, high = ... 这种写法叫解包(unpacking):Python 把每个返回值分别赋给对应的名字。

Juno返回多个值 用逗号分隔多个值,函数就会把它们打包成一个元组一起返回。调用方在一行代码里就能接住它们,low, high = min_max(...),一个值对应一个名字。只要函数天然会产生多个结果,这种写法就很好用。
Juno返回多个值 逗号会把这些值打包成一个元组,调用方用匹配的名字解包。这就是普通的元组打包和解包,不是什么特殊功能。两三个结果的时候很清晰,超过这个数量就不太理想了。
Juno返回多个值 逗号返回打包出一个元组,两三个值还行。超过这个数量,位置就会变成没人看得懂的陷阱,所以应该改成返回 NamedTuple 或 @dataclass,让调用方用 result.average,而不是数下标。

作用域 ​

在函数内部创建的变量只存在于这个函数内部。你从外部是看不到它们的。在所有函数之外定义的变量在任何地方都可见,但你不能不经过显式声明就在函数内部修改它们。

在函数内部创建的名字是局部的(local):它只存在于函数内部,函数返回后就会消失。定义在文件顶层的名字是全局的(global)。在函数内部读取一个全局变量不需要任何特殊处理,但要对它赋值就需要写 global name,否则 Python 会把这次赋值当作创建了一个新的局部变量,把同名的全局变量遮盖掉。实际开发中你几乎不需要用到 global:把值当作参数传进去,再用 return 把结果传出来,能让函数的影响在调用处一目了然。

Python 用 LEGB 规则来解析一个名字,依次检查四个地方:Local(当前函数)、Enclosing(包裹着当前函数的外层函数)、Global(模块)、然后是 Built-in(像 len 这样的内置名字)。读取会沿着这条链查找;而赋值除非你另外声明,否则总是会创建一个局部变量。这种不对称正是 bug 的来源:如果没写 global count 这一行,对一个全局变量执行 count += 1 会抛出 UnboundLocalError,因为这次赋值会让 count 在整个函数里都被标记为局部变量,而读取又发生在任何局部值存在之前。global name 能让某次赋值重新作用到模块层级;nonlocal name 则针对最近的外层函数,这也是闭包用来更新创建它的那个函数中的变量的方式。这两个关键字都该被当作一种代码异味来看待:一个会伸出手去修改外部状态的函数,比一个只接收输入、返回输出的函数,更难测试。

Juno作用域 在函数内部创建的变量只存在于函数内部,外部看不到它。你可以从函数内部读取外层的变量,但要修改它就得先写一行 global。这种情况几乎用不上:把值传进去,把结果返回出来,函数就始终保持简单易懂。
Juno作用域 在函数内部创建的名字是局部的,函数返回后就消失了。读取一个全局变量是免费的,但要写入它就需要 global name,否则 Python 会创建一个把它遮盖掉的局部变量。优先用参数传进去、return 传出来,这样函数的影响在调用处就能一目了然。
Juno作用域 查找名字按 LEGB 规则,但赋值除非你声明了 global 或 nonlocal,否则总是会创建一个局部变量,这就是为什么对一个全局变量执行 count += 1 会抛出 UnboundLocalError。这两个关键字都是一种代码异味:一个会修改外部状态的函数,比一个只接收输入、返回输出的函数更难测试。
python
def calculate():
    result = 42   # 局部于这个函数
    return result

calculate()
print(result)   # NameError,result 在这里并不存在
python
count = 0

def increment():
    global count    # 声明你要修改这个全局变量
    count += 1

increment()
print(count)   # 1

使用 global 应该是最后的手段。它会让代码更难推理。优先把值传进去,再把结果返回出来。作用域直接建立在赋值如何把名字绑定到值这一机制之上,这部分内容在变量与类型一章有详细介绍。

*args 和 **kwargs ​

有时候你无法提前知道一个函数会接收多少个实参。*args 会把任意数量的位置实参收集到一个元组里。**kwargs 会把任意数量的关键字实参收集到一个字典里。args 和 kwargs 这两个名字只是约定俗成,真正起作用的是星号。

*args 会把多出来的位置实参收集到一个元组里。**kwargs 会把多出来的关键字实参收集到一个字典里。两者都可以和普通参数一起使用。顺序是:普通参数在前,然后是 *args,然后是仅限关键字参数,最后是 **kwargs。它们对那种把参数原样转发给另一个函数的包装函数很有用。

*args 把多出来的位置实参收集进一个 tuple,**kwargs 把多出来的关键字实参收集进一个 dict。调用处的操作正好是镜像的:func(*some_list) 会把一个列表展开成位置实参,func(**some_dict) 会把一个字典展开成关键字实参。正是这种对称性让透传式的包装函数得以实现,def timed(*args, **kwargs): return inner(*args, **kwargs) 能把收到的一切原样转发出去,不需要写出任何一个具体参数名。这很有用,但代价是签名的可读性:一个把所有东西都吞进 *args, **kwargs 的包装函数,对调用方和类型检查器来说完全看不出它接受什么,所以只在你真的需要转发任意参数时才用它,而不要把它当成普通函数的默认写法。

python
def total(*args):
    return sum(args)

total(1, 2, 3)          # 6
total(1, 2, 3, 4, 5)   # 15
python
def display(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

display(name="小明", score=87, level=5)

你可以把它们和普通参数混用。普通参数必须写在前面:

python
def describe(title, *tags, **metadata):
    print(f"{title} | tags: {tags} | meta: {metadata}")

describe("Python 入门", "beginner", "python", author="小明", year=2024)
Juno*args 和 **kwargs*args 会把任意数量的多余位置实参收集成一个元组,**kwargs 会把多余的命名实参收集成一个字典。args 和 kwargs 这两个词只是约定,真正起作用的是 * 和 **。当你事先说不清会传进来多少个东西时,这就很好用。
Juno*args 和 **kwargs*args 把多余的位置实参收集进一个元组,**kwargs 把多余的关键字实参收集进一个字典。签名里的顺序是:普通参数,然后 *args,然后仅限关键字参数,最后 **kwargs。对那种把参数直接转发给另一个函数的包装函数最有用。
Juno*args 和 **kwargs* 和 ** 在函数签名里负责收集,在调用处负责展开,这正是包装函数能用 inner(*args, **kwargs) 转发一切的原因。代价是签名对调用方毫无信息量,所以只把它留给真正的透传场景,不要用在普通函数上。

文档字符串(Docstrings) ​

文档字符串是函数顶部的一段字符串,用来描述这个函数做什么。Python 的编辑器和工具会用它在你把鼠标悬停在函数调用上时显示帮助信息。用三重引号书写,简单的函数写一行就够了。

文档字符串是函数体里的第一样东西,是一个用三重引号写的字符串。工具会读取它:help(func) 会把它打印出来,你的编辑器在你把鼠标悬停在调用上时也会显示它,所以它正好在你忘了这个函数是做什么的时候派上用场。惯例是先写一行摘要,如果需要更多细节,空一行再补充。对那种被多处调用的函数,应该把它当作必须写的东西;对一个名字本身已经说明一切的一次性辅助函数,则可以省略。

文档字符串是函数、类或模块体里的第一条语句,它应该说明这个函数做什么、返回什么,而不是把读者从签名里已经能看到的参数列表再复述一遍。它真正有价值的地方在于那些从签名里看不出来的约定:边界情况、会抛出什么异常、空输入会发生什么。如果签名里的类型提示已经带上了类型信息,就跳过那种逐个参数罗列的正式格式,那种细节应该留在类型提示里,而不是在容易过时的说明文字里重复一遍。目标是写出那种你六个月后凌晨两点调试自己写的某个调用时,会希望读到的那一行话。

python
def normalise(value, min_val, max_val):
    """在已知最小值和最大值的情况下,把一个值缩放到 0-1 的范围。"""
    return (value - min_val) / (max_val - min_val)
python
def build_url(base, version, resource, *, secure=True):
    """
    构建一个 API 端点 URL。

    返回一个完整的 URL 字符串。如果 secure 为 False,
    URL 会使用 http 而不是 https。
    """
    scheme = "https" if secure else "http"
    base = base.replace("https://", "").replace("http://", "")
    return f"{scheme}://{base}/{version}/{resource}"

只要一个函数的用途从名字和签名上看不出来,就该给它写文档字符串。

Juno文档字符串(Docstrings) 文档字符串是函数最顶部用三重引号写的一段字符串,描述这个函数做什么。以后你的编辑器会在你悬停鼠标时把它显示出来,那时候你会庆幸自己写了它。只要名字和参数还不能说明用途,就写一个。
Juno文档字符串(Docstrings) 函数体的第一行,用三重引号包起来:help() 和你的编辑器会在悬停时把它显示出来。大多数函数一行摘要就够了,只有行为比较复杂时才在下面补充细节。被多处调用的函数应该写,名字已经说明一切的一行小函数可以省略。
Juno文档字符串(Docstrings) 说明这个函数做什么、返回什么、有哪些边界情况,而不是把读者已经能看到的参数列表再复述一遍。让类型提示去承担类型信息,这样你就不用把同样的事实维护两遍。目标是写出那种你半年后凌晨两点调试自己写的调用时,会希望读到的那一行话。

类型提示 ​

类型提示让你能够标注一个函数期望接收什么类型、返回什么类型。Python 在运行时并不会强制检查它们,但编辑器会利用它们在你运行代码之前就发现错误。冒号前的 -> 用来指定返回类型。

类型提示是一种工具可以验证的文档。编辑器和类型检查器(mypy、pyright)会用它们在运行之前捕捉类型不匹配的问题。在标准 Python 中,它们在运行时没有任何效果。对于没有返回值的函数,正确的标注是 -> None。对于通用容器,用 list[int]、dict[str, int](Python 3.9 以上)。

类型提示在运行时什么都不会改变,有没有它,Python 运行的结果完全一样。它们的全部价值在于程序之外的那一层:类型检查器(mypy 或 pyright)在代码运行之前读取它,以及你的编辑器在你打字时就标出错误的调用。所以一个说谎的类型提示比没有提示更糟糕,因为检查器会信任它。直接用 list[int]、dict[str, int] 和 str | None(在现代 Python 中不需要导入 typing),把不返回任何东西的函数标注成 -> None,这同时也表明把它用在表达式里(x = log(...))是个错误。它的收益体现在那些被很多地方调用的函数上:检查器能捕捉到调用方传错类型,而不是让这个错误在运行时以三层调用之外的一个令人困惑的失败形式冒出来。

python
def greet(name: str, score: int) -> str:
    return f"{name} scored {score}"
python
def log(message: str) -> None:
    print(f"[LOG] {message}")
python
def top_scores(scores: list[int], n: int) -> list[int]:
    return sorted(scores, reverse=True)[:n]

类型提示是可选的,但对于会被多处调用的函数来说非常有价值。它们是工具可以验证的文档。

Juno类型提示 类型提示标注一个函数接收什么、返回什么,比如 name: str,返回类型用 -> str。Python 运行时不会强制检查它们,但你的编辑器会读取它们,在你运行代码之前就提醒你。可以把它们当作工具能够核对的注释。
Juno类型提示 类型提示是一种类型检查器可以验证的文档,运行时没有任何效果。当函数不返回任何东西时,-> None 是正确的标注,list[int] / dict[str, int] 在现代 Python 中不需要导入 typing 就能用。对被多处调用的函数来说值得写。
Juno类型提示 类型提示在运行时什么都不做,它的价值在于检查器和编辑器会读取它,所以一个说谎的提示比没有提示更糟。直接用 str | None 和 list[int],不返回任何东西就标注 -> None。它的好处是能在调用方传错类型三层之外才失败之前,提前把问题抓出来。

把函数当作值 ​

Python 中的函数是值,就像字符串或数字一样。你可以把它们赋值给变量,也可以传给其他函数。这就是 sorted() 能接受一个 key= 函数的原理。

函数是一等对象:它们有类型(function),可以存进变量和集合里,也可以作为参数传递或作为返回值返回。这正是像 sorted(key=...)、map()、filter() 这类高阶函数的基础。

函数是一个按引用传递的对象,从来不会被复制,所以把一个函数交给另一个函数几乎没有代价。由此发展出来的模式是闭包(closure):一个定义在另一个函数内部的函数,即便外层函数已经返回了,它依然能访问外层函数的变量。这就是你即时构建一个"配置好的函数"的方式,比如 make_multiplier(3) 返回一个乘以 3 的函数,3 这个值被捕获并保留下来。实际代码中有两点要留意:闭包捕获的是变量本身,而不是捕获时刻的值,这会坑到那些在循环里创建闭包的人(它们最终都会看到循环结束时的那个值);另外,叠加太多层返回的函数会让调用路径变得难以追踪,所以在闭包能让代码读起来清晰的地方用它,一旦状态变复杂了,就换成类。

python
def double(x):
    return x * 2

def apply(func, value):
    return func(value)

apply(double, 5)   # 10

把函数当作参数传递,在 sorted()、map() 和 filter() 里随处可见。你在lambda 与推导式一章也会见到它。

Juno把函数当作值 函数是一个值,就像字符串或数字一样,所以你可以把它存进变量里,也可以把它交给另一个函数。这正是让你能把一个函数当作 key= 传给 sorted() 的原因。我一开始觉得这挺奇怪的,后来一下就想通了,而且到处都能用上。
Juno把函数当作值 函数是一等对象:可以赋值、存进列表、传递、返回。这正是 sorted(key=...)、map() 和 filter() 背后的引擎,也是你之后要用到的每一个回调函数背后的原理。
Juno把函数当作值 传递一个函数是传引用,不是复制,而闭包能让内层函数在外层函数返回之后依然访问外层的变量。它捕获的是变量本身,不是值,所以在循环里创建闭包每次都会踩坑。一旦被捕获的状态变多,换成类会更清晰。

实践演练 ​

两个配合使用的函数:letter_grade 把分数转换成字母等级,summarise 对列表里的每个分数调用它:

python
def letter_grade(score: int) -> str:
    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    else:
        return "F"

def summarise(scores: list[int]) -> None:
    total = sum(scores)
    avg = total / len(scores)
    grades = []
    for s in scores:
        grades.append(letter_grade(s))
    print(f"Average: {avg:.1f}")
    print(f"Grades: {', '.join(grades)}")

summarise([87, 92, 74, 65, 91])

一个日志格式化函数和一个使用它的文件处理函数,其中 dry_run 默认参数能在没有明确关闭的情况下防止产生副作用:

python
def format_log(level: str, message: str) -> str:
    return f"[{level.upper():5}] {message}"

def process_file(path: str, dry_run: bool = True) -> bool:
    print(format_log("info", f"Processing {path}"))
    if dry_run:
        print(format_log("info", "Dry run, no changes made"))
        return True
    return True

process_file("report.csv")
process_file("report.csv", dry_run=False)

一个单值归一化函数,以及在它基础上构建的一个列归一化函数,带有类型提示和文档字符串。列函数只计算一次范围,然后对每个元素复用那个标量函数:

python
def normalise(value: float, min_val: float, max_val: float) -> float:
    """在已知最小值和最大值的情况下,把 value 缩放到 0-1 的范围。"""
    if max_val == min_val:
        return 0.0
    return (value - min_val) / (max_val - min_val)

def normalise_column(values: list[float]) -> list[float]:
    """对一整列的值做归一化。"""
    lo, hi = min(values), max(values)
    return [normalise(v, lo, hi) for v in values]

raw = [10.0, 25.0, 5.0, 40.0, 15.0]
print(normalise_column(raw))

这里的类型提示有两个作用:一是记录这个函数期望什么样的输入,二是让类型检查器能捕捉到调用方不小心传入了字符串列表这样的错误。