Files
coding-mentor-agent/kb/python-course-kb-practical-python/wiki/concepts/函数.md
T

40 KiB
Raw Blame History

brief, sources
brief sources
函数是 Python 中封装计算、设计接口并传递行为的基本构件。
summaries/07_Advanced_Topics__00_Overview.md
summaries/03_Program_organization__00_Overview.md
summaries/01_Introduction__00_Overview.md
summaries/04_Function_decorators.md
summaries/03_Returning_functions.md
summaries/02_Anonymous_function.md
summaries/01_Variable_arguments.md
summaries/04_More_generators.md
summaries/03_Producers_consumers.md
summaries/01_Dicts_revisited.md
summaries/01_Class.md
summaries/06_Design_discussion.md
summaries/05_Main_module.md
summaries/04_Modules.md
summaries/03_Error_checking.md
summaries/02_More_functions.md
summaries/01_Script.md
summaries/03_Formatting.md
summaries/02_Containers.md
summaries/07_Functions.md
summaries/02_Hello_world.md
summaries/01_Python.md
summaries/00_Overview.md
summaries/00_Setup.md

函数

函数是 Python 中组织程序、复用代码、表达计算过程和设计程序接口的基本单位。它把一组语句封装成一个可调用的操作,使脚本不再只是从上到下堆叠语句,而是由职责清晰、可以组合、测试和复用的构件组成。

函数不仅涉及 def、参数和 return,还涉及调用约定、默认参数、关键字参数、作用域、可变对象、异常处理、文件解析、程序结构、库接口设计,以及更高级的函数作为对象、回调函数和匿名函数 lambda。一个好的函数不仅要完成计算,还应当以清楚、灵活、可预测的方式暴露能力。相关主题包括 python functions、python scripts、script to function、code reuse、Python函数设计、函数抽象、高阶函数 和 lambda匿名函数。

学习目标

学习函数后,应能够:

  • 使用 def 定义自定义函数。
  • 理解参数、函数体、返回值和 return 的作用。
  • 区分位置参数和关键字参数。
  • 使用默认参数设计可选行为。
  • 为可选参数和布尔标志优先使用关键字调用,提高可读性。
  • 理解函数无显式返回值时会返回 None。
  • 使用元组实现多个返回值,并理解解包赋值。
  • 理解局部变量、全局变量和 global 的作用。
  • 理解函数参数传递的是对象引用,而不是对象副本。
  • 区分修改可变对象与重新绑定局部变量。
  • 理解名称必须先定义后使用,以及函数定义顺序和调用顺序的关系。
  • 使用文档字符串为函数添加说明。
  • 使用可选类型注解表达参数和返回值的预期类型。
  • 将简单脚本重构为可复用函数。
  • 编写顶层函数来统一执行一个程序的完整流程。
  • 在交互模式中调用函数进行测试和调试。
  • 理解函数如何通过异常报告错误,并用 try-except 处理异常。
  • 使用标准库函数和模块减少重复实现。
  • 将函数用于真实脚本,例如读取文件、处理 CSV 数据、计算结果和打印报表。
  • 设计更灵活的函数接口,例如接收文件类对象或 可迭代对象,而不是只接收文件名。
  • 理解 concepts/鸭子类型 在函数接口设计中的价值和风险。
  • 理解函数可以作为对象传递给其他函数,例如作为 sort() 的 key 函数。
  • 使用 lambda 定义简短的一次性匿名函数。
  • 理解回调函数、匿名函数和 高阶函数 之间的关系。

前置知识

学习函数前,建议已经理解:

  • Python 基本语法:变量、表达式、缩进和语句块。参见 summaries/01_Python。
  • 如何运行 Python 程序和使用交互式解释器。参见 summaries/02_Hello_world。
  • 文件读取、逐行处理和字符串拆分。参见 summaries/06_Files。
  • 基本类型转换,例如 int()、float()。
  • 简单控制流,例如 if、while、for。
  • 列表、字典、元组和可变对象。参见 summaries/02_Containers。
  • 脚本的基本形式:文件中包含一系列按顺序执行的语句。参见 python scripts。

核心解释

什么是函数

函数是一组执行特定任务的语句。它通常接收输入,执行计算或操作,然后返回结果。

def function_name(arguments):
    statements
    return result

例如:

def sumcount(n):
    '''
    Returns the sum of the first n integers
    '''
    total = 0
    while n > 0:
        total += n
        n -= 1
    return total

调用函数:

a = sumcount(100)

这里:

  • def 用于定义函数。
  • sumcount 是函数名。
  • n 是参数。
  • 函数体是一组缩进语句。
  • return total 指定函数返回值。
  • a = sumcount(100) 调用函数,并把返回结果赋给变量 a。

函数本质上是带名字的一系列语句。Python 中几乎任何语句都可以写在函数内部,例如 import、print()、help()、循环、条件判断、异常处理等。

def foo():
    import math
    print(math.sqrt(2))
    help(math)

相关主题:python functions、code reuse。

函数调用:位置参数与关键字参数

函数可以通过位置参数调用,也可以通过关键字参数调用。

def read_prices(filename, debug):
    ...

位置参数按照函数定义中的顺序传入:

prices = read_prices('prices.csv', True)

关键字参数显式写出参数名:

prices = read_prices(filename='prices.csv', debug=True)

关键字调用通常更清楚,尤其适合布尔标志和可选功能。下面的调用很难看懂:

parse_data(data, False, True)

而下面的写法更能表达意图:

parse_data(data, ignore_errors=True)
parse_data(data, debug=True)
parse_data(data, debug=True, ignore_errors=True)

因此,设计函数时应给参数起短小但有意义的名字。调用者可能会使用关键字参数,IDE、帮助系统和文档工具也会显示这些名字。相关主题:关键字参数、可配置接口。

默认参数与可选行为

如果希望某个参数可选,可以在函数定义中给它默认值:

def read_prices(filename, debug=False):
    ...

调用时可以省略该参数:

d = read_prices('prices.csv')
e = read_prices('prices.dat', True)

带默认值的参数必须放在参数列表末尾,即所有必需参数应先出现,所有可选参数放后面。

默认参数常用于让一个函数支持更多场景,同时保持简单调用。例如 CSV 解析函数可以设计为:

def parse_csv(filename, select=None, types=None, has_headers=True, delimiter=','):
    ...

这样,最常见的调用很短:

records = parse_csv('Data/portfolio.csv')

需要额外行为时再使用关键字参数:

records = parse_csv('Data/portfolio.csv', select=['name', 'shares'], types=[str, int])
prices = parse_csv('Data/prices.csv', types=[str, float], has_headers=False)

相关主题:Python函数设计、函数抽象。

参数与返回值

函数的参数是外部传入函数的数据。返回值是函数计算后交还给调用者的数据。

def portfolio_cost(filename):
    ...
    return total_cost

这个函数的输入是 filename,输出是投资组合的总成本。

return 语句用于返回值:

def square(x):
    return x * x

如果没有显式 return,或者只写 return 而不带表达式,Python 默认返回 None。

def add(a, b):
    total = a + b

x = add(2, 3)      # x 是 None

因此,在处理计算任务时,应尽量明确使用 return,让函数结果清楚可见。

多个返回值

Python 函数实际上只能返回一个对象,但这个对象可以是元组。因此,常见的多个返回值本质上是返回一个元组。

def divide(a, b):
    q = a // b
    r = a % b
    return q, r

调用时可以解包:

x, y = divide(37, 5)   # x = 7, y = 2

也可以作为一个元组接收:

result = divide(37, 5) # result = (7, 2)

相关主题:元组解包、Python返回值。

函数也是对象

Python 中函数不仅是一段可执行代码,也是一种对象。函数名可以绑定到函数对象,函数对象可以赋给变量、放入数据结构、作为参数传给另一个函数,甚至作为返回值从函数中返回。

这使 Python 函数不仅能表达计算,还能表达行为。例如,在通用 CSV 解析函数中,可以把类型转换函数作为参数传入:

row = [func(val) for func, val in zip(types, row)]

这里 types 可能是:

types = [str, int, float]

str、int 和 float 都是可调用对象,它们被当作数据放在列表中,再由解析函数逐个调用。这体现了 concepts/函数作为对象、可调用对象 和 高阶函数 的思想。

回调函数

当一个函数被传给另一个函数,并由后者在执行过程中调用时,这个被传入的函数通常称为回调函数。回调函数常用于把一段可变行为交给通用函数。

排序是典型例子。普通数值列表可以直接排序:

s = [10, 1, 7, 3]
s.sort()
# [1, 3, 7, 10]

也可以降序排序:

s.sort(reverse=True)
# [10, 7, 3, 1]

但如果列表元素是字典或对象,Python 不一定知道应该按哪个字段排序。此时可以传入 key 函数:

def stock_name(s):
    return s['name']

portfolio.sort(key=stock_name)

sort() 会对每个元素调用 stock_name(),用返回值作为排序依据。这里的 stock_name() 就是回调函数。相关主题:concepts/回调函数、排序key函数。

如果元素是对象,也可以按属性排序:

def stock_name(s):
    return s.name

portfolio.sort(key=stock_name)

这种模式的意义是:sort() 负责通用排序流程,调用者只提供如何提取排序关键值的函数。函数接口因此变得更灵活。

匿名函数与 lambda

很多回调函数非常短,只在一次调用中使用。例如:

def stock_name(s):
    return s.name

portfolio.sort(key=stock_name)

如果这个函数只是为了当前排序临时使用,单独起名可能显得冗长。Python 提供 lambda 创建匿名函数:

portfolio.sort(key=lambda s: s.name)

如果数据是字典,也可以写成:

portfolio.sort(key=lambda s: s['name'])

lambda 创建的是一个未命名函数,用来计算单个表达式。它常用于 sort()、sorted() 等需要短小回调函数的地方。

按股票持有数量排序:

portfolio.sort(key=lambda s: s.shares)

按股票价格排序:

portfolio.sort(key=lambda s: s.price)

lambda 的主要优点是简洁:它把一次性的字段提取逻辑直接写在函数调用中,避免定义额外的命名函数。相关主题:lambda匿名函数、匿名函数、排序key函数。

lambda 的限制

Python 的 lambda 有意保持简单:

  • 只能包含一个表达式。
  • 不能包含普通语句。
  • 不能直接写 while、for、try 等语句块。
  • 不适合复杂逻辑。
  • 最常见用途是传入短小的回调函数,例如排序的 key 参数。

因此,选择 def 还是 lambda 的经验规则是:

  • 如果逻辑很短,只使用一次,可以考虑 lambda。
  • 如果逻辑需要命名、复用、调试、文档说明或多条语句,应使用 def。
  • 如果 lambda 让代码变难读,应改成普通函数。

变量作用域

局部变量

函数内部赋值产生的变量是局部变量,只在函数调用期间存在。调用结束后,这些局部变量不会保留,也不能在函数外访问。

def read_portfolio(filename):
    portfolio = []
    for line in open(filename):
        fields = line.split(',')
        s = (fields[0], int(fields[1]), float(fields[2]))
        portfolio.append(s)
    return portfolio

在这个例子中,filename、portfolio、line、fields 和 s 都是局部变量。函数外部访问 fields 会得到 NameError。

局部变量也不会与函数外部的同名变量冲突。这使函数可以把内部实现细节隐藏起来,调用者只需要关心参数和返回值。相关主题:Python作用域。

全局变量

函数可以读取同一文件中的全局变量:

name = 'Dave'

def greeting():
    print('Hello', name)

但是,函数内部的赋值默认会创建局部变量,而不是修改全局变量:

name = 'Dave'

def spam():
    name = 'Guido'

spam()
print(name)      # Dave

核心规则:函数中的所有赋值默认都是局部赋值。

修改全局变量

如果必须修改全局变量,需要使用 global 声明:

name = 'Dave'

def spam():
    global name
    name = 'Guido'

global 声明必须出现在使用该变量之前,并且对应变量应存在于同一文件中。不过,频繁使用 global 通常是糟糕设计的信号。函数如果需要修改外部状态,更好的方式通常是:

  • 把状态作为参数传入并返回新结果。
  • 使用可变对象显式承载状态。
  • 使用类封装状态和行为。

相关主题:全局变量、状态管理、可预测性。

参数传递与对象绑定

调用函数时,参数变量只是绑定到传入对象的名字。传入的值不会被复制。这个规则对可变对象尤其重要。

def foo(items):
    items.append(42)

a = [1, 2, 3]
foo(a)
print(a)          # [1, 2, 3, 42]

这里 items 和 a 指向同一个列表,因此 append() 会修改调用者看到的对象。

但是,重新给参数名赋值只是改变局部变量绑定,不会改变调用者的变量:

def bar(items):
    items = [4, 5, 6]

b = [1, 2, 3]
bar(b)
print(b)          # [1, 2, 3]

关键区别是:

  • 修改对象:可能影响函数外部。
  • 重新绑定变量名:只影响函数内部的局部名字。

变量赋值不会覆盖内存,它只是让名字绑定到新的对象。相关主题:Python参数传递、变量绑定、可变对象。

名称定义顺序与函数组织

Python 中名称必须在被使用前已经定义。变量名、函数名和导入的模块名都遵循这个规则。

def square(x):
    return x * x

a = 42
b = a + 2
z = square(b)

函数可以按任意顺序定义,只要在程序实际执行到函数调用之前,该函数名已经存在即可。

def foo(x):
    bar(x)

def bar(x):
    print(x)

foo(3)

函数体中引用另一个函数并不要求那个函数在文本上已经提前出现;真正的要求是:运行到调用语句时,被调用的函数已经定义。

常见的组织方式是自底向上:先定义小而简单的函数,再定义依赖这些函数的较高级函数,最后在文件末尾调用顶层函数。

相关主题:自底向上设计、程序结构、模块化编程。

函数作为黑盒与接口

理想情况下,函数应像黑盒:调用者只需要知道输入、输出和功能,不需要知道内部细节。

良好的函数通常具有以下特征:

  • 只依赖传入的参数。
  • 尽量避免读取或修改不明显的全局变量。
  • 避免神秘副作用。
  • 相同输入产生可预测的结果。
  • 每个函数负责一个清晰任务。
  • 参数名和返回值能清楚表达接口。
  • 参数类型不要过早绑定到某个具体实现,除非确实需要。
  • 如果需要可变行为,可以考虑把函数作为参数传入。

例如,读取价格文件可以写成独立函数:

def read_prices(filename):
    prices = {}
    with open(filename) as f:
        f_csv = csv.reader(f)
        for row in f_csv:
            prices[row[0]] = float(row[1])
    return prices

之后可以重复使用:

oldprices = read_prices('oldprices.csv')
newprices = read_prices('newprices.csv')

这比在脚本中反复复制读取逻辑更清晰,也更容易调试。主要目标包括 模块化编程、可预测性 和 可维护性。

函数接口设计:文件名还是可迭代对象

在文件处理函数中,一个重要设计问题是:函数应该接收文件名,还是接收已经打开的文件类对象或可迭代的行对象?

第一种设计让函数接收文件名,并在函数内部打开文件:

def read_data(filename):
    records = []
    with open(filename) as f:
        for line in f:
            ...
            records.append(r)
    return records

第二种设计让函数接收可迭代的文本行:

def read_data(lines):
    records = []
    for line in lines:
        ...
        records.append(r)
    return records

第二种通常更灵活。它不关心输入来自普通文件、压缩文件、标准输入,还是测试时手写的字符串列表;它只关心参数能否被逐行迭代。

lines = open('data.csv')
data = read_data(lines)

lines = gzip.open('data.csv.gz', 'rt')
data = read_data(lines)

lines = sys.stdin
data = read_data(lines)

lines = ['ACME,50,91.1', 'IBM,75,123.45']
data = read_data(lines)

这体现了 concepts/鸭子类型:如果一个对象能像行序列一样被 for line in lines 使用,那么函数就可以把它当作行序列处理,而不必关心它的具体类型。相关主题:接口设计、库设计、可迭代对象、文件处理。

不过,这种设计也有陷阱。字符串本身也是可迭代对象。如果把旧接口中的文件名字符串直接传给新版本函数:

parse_csv('Data/portfolio.csv', types=[str, int, float])

函数可能会把文件名当作一个字符序列逐字符处理,而不是打开该文件。因此,在把函数从接收文件名改为接收行对象时,应考虑安全检查,例如检测参数是否为字符串路径,并抛出更清楚的错误,或在上层函数中负责打开文件。

常见职责划分是:

  • 底层解析函数接收行对象,专注于解析。
  • 上层业务函数接收文件名,负责打开文件并把文件对象传给解析函数。
  • 测试代码可以直接传入字符串列表,避免依赖真实文件。

文档字符串与类型注解

如果函数体的第一条语句是字符串,它会成为函数的文档字符串,也称 docstring。

def greeting(name):
    'Issues a greeting'
    print('Hello', name)

可以通过 help() 查看:

help(greeting)

好的文档字符串通常包括:

  • 一句话概括函数做什么。
  • 必要时说明参数含义。
  • 必要时说明返回值。
  • 必要时提供简短使用示例。

函数定义也可以添加可选类型提示:

def read_prices(filename: str) -> dict:
    '''
    Read prices from a CSV file of name,price data
    '''
    prices = {}
    ...
    return prices

类型注解不会改变 Python 的运行行为。它们主要是信息性的,但可以被 IDE、类型检查器、代码检查器和文档工具使用。相关主题:documentation、代码文档化、concepts/类型注解、静态分析。

从脚本到函数

脚本是按顺序执行一系列语句并结束的程序:

statement1
statement2
statement3

Python 很容易写出这种直接的脚本,但随着功能增加,脚本可能变成难以维护的大文件。因此,函数的重要用途之一,是把原本写死在脚本中的逻辑提取出来。

例如,原本脚本可能直接读取某个固定文件并打印结果:

cost = portfolio_cost('Data/portfolio.csv')
print('Total cost:', cost)

更好的结构是把核心计算封装为函数:

def portfolio_cost(filename):
    ...
    return cost

cost = portfolio_cost('Data/portfolio.csv')
print('Total cost:', cost)

这样做的好处包括:

  • 可以对不同文件重复调用同一段逻辑。
  • 可以在交互模式中单独测试函数。
  • 可以在其他程序中导入并复用该函数。
  • 可以把计算逻辑和脚本入口逻辑分离。
  • 当脚本增长时,更容易继续拆分和维护。

相关主题:script to function、interactive testing。

顶层执行函数

把脚本的完整执行流程封装到一个顶层函数中,可以让程序结构更清楚。

def portfolio_report(portfolio_filename, prices_filename):
    portfolio = read_portfolio(portfolio_filename)
    prices = read_prices(prices_filename)
    report = make_report(portfolio, prices)
    print_report(report)

然后文件末尾只保留一个调用:

portfolio_report('Data/portfolio.csv', 'Data/prices.csv')

这种结构让程序更清楚:

  1. 前面是一组函数定义。
  2. 最后是一个顶层函数调用。
  3. 顶层函数描述完整程序流程。
  4. 低层函数负责具体任务。

如果底层 parse_csv() 已经改为接收文件对象,那么 read_portfolio() 和 read_prices() 这样的上层函数可以继续接收文件名,但在内部打开文件:

def read_portfolio(filename):
    with open(filename) as file:
        return parse_csv(file, types=[str, int, float])

这样,旧的脚本调用方式保持不变,而底层解析能力变得更灵活。相关主题:script to function、程序结构、模块化编程、python scripts。

函数与标准库

函数不仅可以由用户自定义,也可以来自 Python 标准库。标准库通过 import 使用。

import math
x = math.sqrt(10)

在处理 CSV 文件时,应优先使用标准库中的 csv 模块,而不是手写 line.split(',')。

import csv

f = open('Data/portfolio.csv')
rows = csv.reader(f)
headers = next(rows)

for row in rows:
    print(row)

f.close()

csv 模块能处理引号、逗号拆分等底层细节,比手动字符串拆分更可靠。相关主题:python standard library、modules and imports、csv processing、data parsing。

示例:通用 CSV 解析函数

parse_csv() 是函数抽象和接口设计的典型例子。它把调用 csv.reader()、跳过空行、选择列、类型转换、处理表头和分隔符等细节封装在一个可复用函数中。

基础版本把带表头的 CSV 解析为字典列表:

import csv

def parse_csv(lines):
    '''
    Parse CSV lines into a list of records
    '''
    rows = csv.reader(lines)
    headers = next(rows)
    records = []
    for row in rows:
        if not row:
            continue
        record = dict(zip(headers, row))
        records.append(record)
    return records

注意这里的参数名是 lines,不是 filename。这表示该函数需要的是可迭代的文本行,而不是路径字符串。

进一步改进后,可以支持列选择:

shares_held = parse_csv(file, select=['name', 'shares'])

列选择的关键是把列名映射为索引:

indices = [headers.index(colname) for colname in select]
row = [row[index] for index in indices]

还可以支持类型转换:

portfolio = parse_csv(file, types=[str, int, float])

转换逻辑把函数对象当作数据传入:

row = [func(val) for func, val in zip(types, row)]

无表头文件可以用 has_headers=False 返回元组列表:

prices = parse_csv(file, types=[str, float], has_headers=False)

不同分隔符可以通过 delimiter 参数配置:

portfolio = parse_csv(file, types=[str, int, float], delimiter=' ')

这个例子体现了几个重要思想:

  • 使用函数隐藏底层细节。
  • 使用默认参数提供常见行为。
  • 使用关键字参数配置可选功能。
  • 使用列表推导式、zip()、dict()、元组和类型转换组合解决实际问题。
  • 将重复的文件解析逻辑抽象成小型库函数。
  • 通过接收可迭代行对象,让函数支持普通文件、gzip 文件、标准输入和测试列表。
  • 把函数对象作为参数传递,实现可配置的数据转换。
  • 在接口更灵活后,需要用安全检查避免把字符串文件名误当作字符序列。

相关主题:CSV解析、列选择、数据投影、类型转换、高阶函数、可调用对象、函数抽象、concepts/鸭子类型、可迭代对象。

示例:排序、key 函数与 lambda

排序展示了函数作为行为参数的另一种常见用法。

如果有一组股票对象:

Stock('AA', 100, 32.2)
Stock('IBM', 50, 91.1)
Stock('CAT', 150, 83.44)

可以按名称排序:

def stock_name(s):
    return s.name

portfolio.sort(key=stock_name)

也可以用 lambda 直接写出字段提取逻辑:

portfolio.sort(key=lambda s: s.name)

按股数排序:

portfolio.sort(key=lambda s: s.shares)

按价格排序:

portfolio.sort(key=lambda s: s.price)

这个例子说明:函数接口可以把通用算法和特定行为分离。sort() 实现排序算法;key 函数或 lambda 告诉它按什么值排序。这是 concepts/回调函数、排序key函数、高阶函数 和 lambda匿名函数 的交汇点。

函数与异常

函数通常通过异常报告错误。如果异常没有被处理,函数会中止,整个程序也可能停止。

>>> int('N/A')
Traceback ...
ValueError: invalid literal for int() with base 10: 'N/A'

异常信息通常包含:

  • 错误类型,例如 ValueError。
  • 错误原因。
  • 错误发生的位置。
  • traceback,即导致错误的一系列调用路径。

可以使用 try-except 捕获并处理异常。

for line in file:
    fields = line.split(',')
    try:
        shares = int(fields[1])
    except ValueError:
        print('Could not parse', line)

函数也可以主动抛出异常,用来报告无法继续执行的错误状态。

raise RuntimeError('What a kerfuffle')

在接口设计中,异常也可以用于防止误用。例如,如果函数已经改为接收文件类对象,却收到字符串文件名,可以主动抛出 TypeError,提示调用者先 open() 文件。

相关主题:python exceptions、error handling、exception raising、debugging、robust programming、data cleaning、fault tolerance。

函数与命令行脚本

函数可以与命令行参数结合,使脚本更灵活。

import sys

def portfolio_cost(filename):
    ...

if len(sys.argv) == 2:
    filename = sys.argv[1]
else:
    filename = 'Data/portfolio.csv'

cost = portfolio_cost(filename)
print('Total cost:', cost)

运行:

python3 pcost.py Data/portfolio.csv

这样,核心逻辑仍在 portfolio_cost() 函数中,而输入文件名可以从命令行指定。如果底层解析函数接收文件对象,命令行脚本仍可以在上层打开命令行给出的文件名,再把文件对象传入解析函数。相关主题:command line arguments、python scripts。

典型代码示例

简单问候函数

def greeting(name):
    'Issues a greeting'
    print('Hello', name)

greeting('Guido')
greeting('Paula')

投资组合成本函数

def portfolio_cost(filename):
    total_cost = 0.0
    f = open(filename)
    headers = next(f)

    for line in f:
        fields = line.split(',')
        shares = int(fields[1])
        price = float(fields[2])
        total_cost += shares * price

    f.close()
    return total_cost

报表打印函数

def print_report(report):
    headers = ('Name', 'Shares', 'Price', 'Change')
    print('%10s %10s %10s %10s' % headers)
    print(('-' * 10 + ' ') * len(headers))
    for row in report:
        print('%10s %10d %10.2f %10.2f' % row)

顶层报表函数

def portfolio_report(portfolio_filename, prices_filename):
    portfolio = read_portfolio(portfolio_filename)
    prices = read_prices(prices_filename)
    report = make_report(portfolio, prices)
    print_report(report)

接收可迭代行对象的解析函数

def read_data(lines):
    records = []
    for line in lines:
        ...
        records.append(record)
    return records

排序 key 函数

def stock_name(s):
    return s.name

portfolio.sort(key=stock_name)

使用 lambda 排序

portfolio.sort(key=lambda s: s.name)
portfolio.sort(key=lambda s: s.shares)
portfolio.sort(key=lambda s: s.price)

常见错误

忘记调用函数

定义函数不会自动运行函数。必须显式调用。

def greeting(name):
    print('Hello', name)

greeting('Guido')

调用发生得太早

foo(3)

def foo(x):
    print(x)

执行到 foo(3) 时,foo 还没有定义,因此会出错。

忘记 return

def add(a, b):
    total = a + b

x = add(2, 3)     # None

应写成:

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

把打印当作返回值

def add(a, b):
    print(a + b)

x = add(2, 3)     # x 是 None

如果后续还要使用结果,应使用 return。

可选参数调用不清楚

parse_data(data, False, True)

这种调用难以理解。更好的写法是:

parse_data(data, debug=True, ignore_errors=False)

参数数量不匹配

def greeting(name):
    print('Hello', name)

greeting()          # 缺少参数
greeting('A', 'B')  # 参数过多

缩进错误

Python 使用缩进表示函数体。缩进错误会导致语法错误或逻辑错误。

误解全局变量赋值

name = 'Dave'

def spam():
    name = 'Guido'

spam()
print(name)       # Dave

函数内部赋值默认创建局部变量,不会修改全局变量。

滥用 global

虽然 global 可以修改全局变量,但通常应避免。若函数需要修改外部状态,优先考虑通过返回值、显式参数或类来表达状态变化。

误解参数传递

def foo(items):
    items.append(42)

如果传入列表,这会修改原列表。函数并不会自动复制参数。

混淆修改对象与重新赋值

def bar(items):
    items = [4, 5, 6]

这只是让局部变量 items 指向新列表,不会改变调用者的列表。

函数依赖隐藏的全局状态

filename = 'Data/portfolio.csv'

def read_data():
    return open(filename).read()

更好的写法是把文件名作为参数传入:

def read_data(filename):
    return open(filename).read()

在更通用的库函数中,还可以进一步接收打开的文件或行对象:

def read_data(lines):
    for line in lines:
        ...

把文件名误传给接收行对象的函数

如果函数已经改为接收可迭代行对象,传入文件名字符串会导致函数逐字符处理路径:

parse_csv('Data/portfolio.csv')

这是因为字符串本身也是可迭代对象。应改为:

with open('Data/portfolio.csv') as file:
    parse_csv(file)

或者让上层函数继续接收文件名并负责打开文件。

手动解析 CSV 过于简单

fields = line.split(',')

这种方式在简单数据中可用,但遇到带引号、嵌入逗号或复杂格式的 CSV 时容易出错。应优先使用 csv.reader()。

只处理正常数据,不处理异常数据

真实文件可能包含缺失字段、空字符串或格式错误。若直接转换:

shares = int(fields[1])

可能触发 ValueError。应根据需求使用 try-except 处理。

为一次性小函数写过多样板代码

如果函数只用于一次排序,并且只返回某个字段:

def stock_price(s):
    return s.price

portfolio.sort(key=stock_price)

可以考虑使用 lambda:

portfolio.sort(key=lambda s: s.price)

但如果逻辑复杂、需要复用或需要文档说明,仍应使用普通 def 函数。

过度使用 lambda

lambda 只能表达单个表达式。如果匿名函数变得很长或难读,应改成命名函数。可读性通常比少写几行代码更重要。

调试提示

  • 使用交互式解释器单独调用函数,验证输入和输出。
  • 用 python3 -i script.py 运行脚本,加载函数后继续实验。
  • 先让函数处理一个小输入,再扩大到完整文件。
  • 出现异常时仔细阅读 traceback,定位具体文件、行号和调用链。
  • 对文件处理函数,先打印读取到的行或字段,确认解析结果是否符合预期。
  • 对数据转换语句,例如 int()、float(),重点检查空字符串、缺失字段和非数字内容。
  • 使用关键字参数让测试调用更可读。
  • 注意函数是否修改了传入的可变对象。
  • 使用标准库模块代替脆弱的手写解析逻辑。
  • 如果函数越来越长,考虑拆分为多个更小的函数。
  • 如果脚本末尾越来越复杂,考虑创建一个顶层函数来表达完整执行流程。
  • 尽量让函数输入来自参数,输出来自返回值,减少隐藏依赖和副作用。
  • 为关键函数编写简短 docstring,让未来的自己或其他使用者更容易理解。
  • 测试文件解析函数时,可以传入字符串列表,而不必总是创建真实文件。
  • 当函数改为接收可迭代对象后,专门测试误传字符串文件名的情况。
  • 在库函数中尽量面向行为和协议设计接口,同时用清晰错误信息防止常见误用。
  • 调试排序时,先确认 key 函数或 lambda 对单个元素返回了预期值。
  • 如果 lambda 难以调试,可以先改写成命名函数,再单独调用测试。

推荐练习

  1. 定义一个 greeting(name) 函数,调用多次,并使用 help(greeting) 查看文档字符串。
  2. 编写 sumcount(n),返回前 n 个正整数之和。
  3. 将一个直接运行的脚本改造成函数,例如 portfolio_cost(filename)。
  4. 使用 python3 -i 进入交互模式,手动调用自己写的函数。
  5. 给 portfolio_cost() 加入异常处理,使它能跳过格式错误的数据行。
  6. 将手写的 CSV 拆分逻辑改为使用 csv.reader()。
  7. 使用 sys.argv 让脚本从命令行接收文件名。
  8. 编写一个函数,在输入不合法时用 raise 主动抛出异常。
  9. 把报表打印逻辑封装为 print_report(report)。
  10. 创建顶层函数 portfolio_report(portfolio_filename, prices_filename),让脚本末尾只保留一次顶层函数调用。
  11. 为一个已有函数添加 docstring 和类型注解。
  12. 为函数增加默认参数,并用关键字参数调用它。
  13. 编写 divide(a, b),返回商和余数,并分别用解包和元组两种方式接收结果。
  14. 实验列表参数:一个函数修改列表,另一个函数重新绑定参数名,观察差异。
  15. 实现通用 parse_csv(),支持 select、types、has_headers 和 delimiter。
  16. 将 parse_csv() 从接收文件名改为接收文件类对象或可迭代行对象。
  17. 使用 gzip.open()、sys.stdin 和字符串列表测试同一个解析函数。
  18. 给接收行对象的函数加入安全检查,避免误传文件名字符串。
  19. 修改 read_portfolio() 和 read_prices(),让它们负责打开文件并调用新的 parse_csv()。
  20. 定义 stock_name(s),用 portfolio.sort(key=stock_name) 按股票名称排序。
  21. 使用 lambda s: s.shares 按持股数量排序。
  22. 使用 lambda s: s.price 按股票价格排序。
  23. 对比命名 key 函数和 lambda 写法,判断哪一种在当前场景更清楚。

关联知识点

  • python functions:Python 函数定义、调用、参数和返回值。
  • Python函数设计:设计清晰、可读、可复用的函数接口。
  • 函数抽象:将重复低层逻辑封装为通用函数。
  • 接口设计:为函数和模块设计清晰、稳定、灵活的使用方式。
  • 库设计:编写可复用代码时优先面向通用协议和组合能力。
  • concepts/鸭子类型:根据对象行为而不是具体类型判断是否可用。
  • 可迭代对象:能被 for 循环逐项访问的对象。
  • 文件处理:打开、读取和管理文件资源。
  • 关键字参数:使用参数名调用函数,提高可选参数可读性。
  • Python返回值:理解 return、None 和元组返回。
  • 元组解包:接收函数返回的多个值。
  • Python作用域:局部变量、全局变量和名称查找规则。
  • 全局变量:函数读取和修改全局状态的规则与风险。
  • 状态管理:避免用全局变量隐式管理程序状态。
  • Python参数传递:函数参数绑定到对象而不是复制对象。
  • 变量绑定:赋值让名字绑定到对象。
  • 可变对象:列表、字典等对象可能被函数原地修改。
  • code reuse:通过函数减少重复代码。
  • documentation:使用文档字符串说明函数用途。
  • 代码文档化:为函数和程序添加可读说明。
  • concepts/类型注解:为函数参数和返回值提供可选类型信息。
  • 静态分析:使用工具检查代码和类型提示。
  • script to function:把脚本重构为可复用函数。
  • interactive testing:在交互模式中测试函数。
  • python standard library:使用标准库中的现成模块和函数。
  • modules and imports:通过 import 引入模块。
  • python exceptions:Python 异常机制。
  • error handling:使用 try-except 处理错误。
  • exception raising:使用 raise 主动抛出异常。
  • debugging:利用 traceback 和交互测试定位问题。
  • csv processing:使用 csv 模块处理 CSV 文件。
  • CSV解析:把 CSV 文本转换为结构化数据。
  • data parsing:将文本数据转换为结构化数据。
  • data cleaning:处理缺失、错误和异常数据。
  • 列选择:从结构化数据中提取指定字段。
  • 数据投影:只保留计算所需的数据列。
  • 类型转换:把字符串字段转换为 int、float 等类型。
  • 高阶函数:把函数对象作为数据传递或接收函数作为参数。
  • concepts/函数作为对象:函数可以赋值、传参和组合。
  • 可调用对象:可以像函数一样调用的对象。
  • concepts/回调函数:传入另一个函数并由后者调用的函数。
  • 排序key函数:为排序操作提供比较依据的函数。
  • lambda匿名函数:用 lambda 定义简短的一次性匿名函数。
  • 匿名函数:没有显式名称的函数形式。
  • 可配置接口:用参数控制函数的不同行为。
  • command line arguments:使用 sys.argv 读取命令行参数。
  • python scripts:编写可从终端运行的 Python 脚本。
  • 程序结构:组织定义、执行流程和依赖关系的方式。
  • 模块化编程:把程序拆分成职责明确的小部件。
  • 自底向上设计:先构建简单函数,再组合为复杂功能。
  • 可预测性:让函数行为容易推理和验证。
  • 可维护性:让程序在增长后仍然容易理解和修改。

对应教材来源

来源:Practical Python Programming, https://github.com/dabeaz-course/practical-python

See also: summaries/00_Setup

See also: summaries/00_Overview

See also: summaries/01_Python

See also: summaries/02_Hello_world

See also: summaries/06_Files

See also: summaries/07_Functions

See also: summaries/02_Containers

See also: summaries/03_Formatting

See also: summaries/01_Script

See also: summaries/02_More_functions

See also: summaries/03_Error_checking

See also: summaries/04_Modules

See also: summaries/05_Main_module

See also: summaries/06_Design_discussion

See also: summaries/01_Class

See also: summaries/01_Dicts_revisited

See also: summaries/03_Producers_consumers

See also: summaries/04_More_generators

See also: summaries/01_Variable_arguments

See also: summaries/02_Anonymous_function

See also: summaries/03_Returning_functions

See also: summaries/04_Function_decorators

See also: summaries/01_Introduction__00_Overview

See also: summaries/03_Program_organization__00_Overview

See also: summaries/07_Advanced_Topics__00_Overview