函数使用(二)

前言

  • 这一篇讲两件事:函数定义的几条规矩,以及变量作用域(全局 / 局部 / global / nonlocal)和递归。
  • 每个小节后面都配了一个把该节知识点全用上一遍的完整示例,输出是本机 Python 3.13.9 实跑的结果。
  • 作用域这块最容易踩的是「函数里先读后写同名变量」和「改全局忘了 global」,示例里都专门演示了报错长什么样。

函数回顾

  • 函数的定义位置是随意的吗?

    • 不是。Python 自上而下执行,调用发生时函数必须已经定义好,否则会 NameError。
    • 但这约束的是「调用」而不是「书写顺序」:函数体里引用后面才定义的函数完全没问题,因为函数体是在被调用时才执行的(递归更是自己引用自己)。
  • 函数的名字可以相同吗?

    • 不能靠同名做「重载」:Python 没有 C++/Java 那种按参数类型、个数区分的函数重载,同名定义就是后一个覆盖前一个(函数不过是绑定在这个名字上的对象)。
    • 要按参数分支就用默认参数、*args / **kwargs,或者标准库的 functools.singledispatch(按第一个参数的类型分派)。
  • 函数中能否再定义函数?

    • 能。函数内部定义的函数叫内部函数,它是闭包和装饰器的基础;内层函数引用外层函数的变量时,用 nonlocal 才能改那个变量(见下一节)。
  • 整体示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    from functools import singledispatch

    # 1) 先定义后调用:只要「调用发生时」函数已经存在就行,
    # 函数体内引用后面才定义的函数完全没问题
    def a():
    return b() + 1 # 定义 a 的时候 b 还不存在

    def b():
    return 10

    print('a() =', a())

    # 2) 同名会直接覆盖,Python 没有按参数类型/个数区分的函数重载
    def f():
    return '第一个'

    def f(): # 覆盖掉上面那个
    return '第二个'

    print('同名覆盖后调用:', f())

    # 3) 真想要「不同参数走不同实现」:默认参数 / *args,或 functools.singledispatch
    def g(n, times=1):
    return n * times

    print('默认参数:', g(3), g(3, 4))

    @singledispatch
    def show(x):
    return '默认分支: %r' % x

    @show.register(int)
    def _(x):
    return '整数分支: %d' % x

    print('singledispatch:', show(7), '/', show('7'))

    # 4) 函数里还能再定义函数(内部函数,闭包和装饰器的基础)
    def outer():
    def inner():
    return 'inner'
    return inner()

    print('内部函数:', outer())

    实跑输出:

    1
    2
    3
    4
    5
    a() = 11
    同名覆盖后调用: 第二个
    默认参数: 3 12
    singledispatch: 整数分支: 7 / 默认分支: '7'
    内部函数: inner

变量作用域

  • 块级作用域

    Python 没有块级作用域:if / for / while / try 这些语句块里赋值的名字,出了块还能用(下面代码正常运行)。

    1
    2
    3
    4
    if True:
    name = 'xiaoming'

    print(name) # xiaoming —— 块外照样能拿到

    两个例外要记住:函数(含 lambda)和推导式都有自己独立的作用域,类体也是一个独立命名空间——所以类定义里写 [y for _ in range(1)] 反而看不到类变量 y。

  • 局部作用域

    局部作用域:函数内部赋值的变量是局部变量,只能在函数内部使用,函数返回后就没了。

    1
    2
    3
    4
    5
    6
    def test():
    a = 10
    return a

    test()
    print(a) # NameError: name 'a' is not defined
  • 全局作用域

    全局变量:定义在函数外部的变量。函数里读它不需要任何声明;但只要在函数里给它赋过值,这个名字在整个函数内都算局部变量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    num = 20

    def show():
    global num # 声明了才能改模块级的那个 num
    num = 30
    print(num)

    show() # 30
    print(num) # 30 —— 全局的确实被改了

    说明:

    1. 不加 global 时函数里的 num = ... 会新建一个局部变量,全局的不动;而如果先读后写(print(num) 在前、num = ... 在后),因为赋值让 num 在整个函数里都是局部的,那一句读会抛 UnboundLocalError: local variable 'num' referenced before assignment—— 这不是「优先使用内部的」,而是同一个名字不会一半局部一半全局。
    2. 想改全局变量必须 global 声明;改外层函数的变量用 nonlocal(global 管不到函数作用域)。
    3. 少用 global:函数依赖外部状态后不好测试,参数进、返回值出更清楚。
  • nonlocal

    内层函数要修改外层函数的局部变量时用它(只读的话不用声明)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    def wai():
    age = 20

    def nei():
    nonlocal age # 声明改的是外层的 age,不是新建局部变量
    age = 30
    print(age) # 30

    nei()
    print(age) # 30 —— 外层看到内层的修改

    wai()

    nonlocal 只能绑到外层函数的变量;在模块顶层或没有同名外层变量时用它,直接 SyntaxError: no binding for nonlocal。

  • 整体示例

    把上面这些点全用一遍

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    name = '全局的 name'
    count = 0

    # 1) Python 没有块级作用域:if / for / while / try 里赋的名字,块外照样能拿到
    if True:
    block_var = 'if 块里定义的'
    for k in range(2):
    pass
    try:
    raise ValueError('演示')
    except ValueError as err:
    caught = 'except 块里定义的 ' + type(err).__name__
    print('块外的变量:', block_var, '/', 'for 的循环变量还在:', k, '/', caught)
    print('但 except 的 err 在块结束后被自动删除:', 'err' in dir())

    # 2) 局部作用域:函数里赋值的名字是局部变量,函数返回就没了
    def local_demo():
    a = 10
    return a

    print('函数内部用局部变量:', local_demo())
    try:
    print(a)
    except NameError as e:
    print('函数外访问局部变量 ->', type(e).__name__)

    # 3) 读全局不用声明;但函数里一旦有赋值,这个名字在整个函数内都是局部的
    def read_global():
    return name

    print('函数里直接读全局:', read_global())

    def broken():
    print(name) # 下一行有 name = ...,所以这里读的是「还没赋值的局部变量」
    name = '局部的 name'

    try:
    broken()
    except UnboundLocalError as e:
    print('先读后写 ->', type(e).__name__)

    def use_global():
    global count # 声明了才能改模块级的那个变量
    count += 1
    return count

    print('global 改全局:', use_global(), ' 模块里的值变成:', count)

    # 4) nonlocal 改的是外层函数的局部变量;global 管不到函数作用域
    def outer():
    x = '外层的 x'

    def mid():
    nonlocal x
    x = '被内层改了'

    mid()
    return x

    print('nonlocal:', outer())

    # 5) 推导式和 lambda 有自己独立的作用域,循环变量不外泄
    squares = [i * i for i in range(3)]
    print('推导式结果:', squares, ' 它的 i 是否泄漏到外面:', 'i' in dir())

    # 6) 类体是独立命名空间:类定义里写推导式看不到类变量
    class Demo:
    y = 4
    try:
    z = [y for _ in range(1)]
    except NameError:
    z = 'NameError'

    print('类体里推导式看不到类变量:', Demo.z)

    实跑输出:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    块外的变量: if 块里定义的 / for 的循环变量还在: 1 / except 块里定义的 ValueError
    但 except 的 err 在块结束后被自动删除: False
    函数内部用局部变量: 10
    函数外访问局部变量 -> NameError
    函数里直接读全局: 全局的 name
    先读后写 -> UnboundLocalError
    global 改全局: 1 模块里的值变成: 1
    nonlocal: 被内层改了
    推导式结果: [0, 1, 4] 它的 i 是否泄漏到外面: False
    类体里推导式看不到类变量: NameError

递归函数

  • 定义:函数内部调用函数本身的函数叫递归函数

  • 组成:

    • 函数内部调用自己
    • 终止条件
    • 核心算法
  • 特点:

    • 代码简洁,但层数一深可读性就下降(不易理解)。
    • 每一层调用都要占一个栈帧,所以内存随深度线性增长;没有终止条件不会「立即」崩,而是一路撞到递归深度上限后抛 RecursionError。
    • 这个上限别背数字,要现查 sys.getrecursionlimit():本机命令行跑是 1000,同一份代码在 PyCharm 里运行是 3000(IDE 注入的运行环境会把它调高),换个环境就换个数字。而且它只是「允许多深」,不等于「一定能多深」:实测上限 1000 时真正能递归到 998 层(跑这段测试时栈上已经有帧了),把上限设成 3000 时能到 2998 层。
    • sys.setrecursionlimit() 可以调,但调太大只是把 RecursionError 换成更狠的崩溃:栈是操作系统分给进程的真实资源,超过栈大小会直接段错误(segfault),连异常都拿不到。
    • Python 没有尾调用优化,尾递归不会退化成循环,深递归场景改写成迭代更稳。
    • 有些安全关键领域的编码规范直接禁用递归(如汽车电子的 MISRA C:禁止递归、禁止动态内存)。
    • 实用建议:树形结构、目录遍历、分治这类「天生递归」的问题用它最自然;能用循环清楚表达的(求和、计数)就别递归。递归慢或重复计算多时,加 functools.lru_cache 记忆化往往就够了。
  • 整体示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    import functools
    import os
    import sys

    # 1) 阶乘:终止条件 + 调用自己(文末练习第 1 题)
    def fact(n):
    if n <= 1: # 终止条件
    return 1
    return n * fact(n - 1) # 调用自己

    print('fact(5) =', fact(5))

    # 2) 斐波那契(文末练习第 2 题):朴素递归会重复计算,n 一大就慢
    def fib(n):
    if n < 2:
    return n
    return fib(n - 1) + fib(n - 2)

    print('fib(20) =', fib(20))

    # 3) 加个记忆化装饰器,同样的递归从指数级降到线性
    @functools.lru_cache(maxsize=None)
    def fib_cached(n):
    if n < 2:
    return n
    return fib_cached(n - 1) + fib_cached(n - 2)

    print('fib_cached(60) =', fib_cached(60))
    print('确实命中过缓存:', fib_cached.cache_info().hits > 0)

    # 4) 迭代版更稳:Python 没有尾调用优化,递归层数深就是风险
    def fib_loop(n):
    a, b = 0, 1
    for _ in range(n):
    a, b = b, a + b
    return a

    print('迭代与记忆化结果一致:', fib_loop(60) == fib_cached(60))

    # 5) 没有终止条件不是「立即」崩,而是撞到递归深度上限
    print('本机递归上限(换环境就不同,别背数字):', sys.getrecursionlimit())

    def probe(n=0): # 没有终止条件的递归:一路到底,看实际能到几层
    try:
    return probe(n + 1)
    except RecursionError:
    return n

    print('无限递归撞到上限 -> 实际能到:', probe())

    # 6) 递归真正自然的场景:树形结构 / 目录遍历
    os.makedirs('tree/a/b', exist_ok=True)
    for p in ('tree/top.txt', 'tree/a/mid.txt', 'tree/a/b/low.txt'):
    with open(p, 'w') as fp:
    fp.write('x')

    def walk(root):
    total = 0
    for entry in sorted(os.listdir(root)):
    path = os.path.join(root, entry)
    if os.path.isdir(path):
    total += 1 + walk(path) # 目录:自己进去继续数
    else:
    total += 1 # 文件:数一个
    return total

    print('tree 下 2 个子目录 + 3 个文件 =', walk('tree'))

    # 7) 汉诺塔:三层正好 2**3 - 1 步
    def hanoi(n, src='A', dst='C', via='B'):
    if n == 0:
    return []
    return hanoi(n - 1, src, via, dst) + [(n, src, dst)] + hanoi(n - 1, via, dst, src)

    steps = hanoi(3)
    print('汉诺塔 3 层步数:', len(steps), ' 第一步:', steps[0], ' 最后一步:', steps[-1])

    实跑输出:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    fact(5) = 120
    fib(20) = 6765
    fib_cached(60) = 1548008755920
    确实命中过缓存: True
    迭代与记忆化结果一致: True
    本机递归上限(换环境就不同,别背数字): 1000
    无限递归撞到上限 -> 实际能到: 998
    tree 下 2 个子目录 + 3 个文件 = 5
    汉诺塔 3 层步数: 7 第一步: (1, 'A', 'C') 最后一步: (1, 'A', 'C')

练习

1
2
1.求阶乘
2.斐波那契数列(1,1,2,3,5,8,13,21,34,...)
本文结束 感谢您的阅读
正在加载今日诗词....