5. 模块化编程

2023/08/02閱讀時間約 33 分鐘

HI, 大家好。我是茶桁。

上一节中我们学习了Python基本的流程控制,并且预告了这一节的内容,就是将要学习「模块化编程」。那什么是模块化编程呢?按照维基百科的说法:

模块化编程(英语:modular programming),是强调将计算机程序的功能分离成独立的、可相互改变的“模块)”(module)的软件设计技术,它使得每个模块都包含着执行预期功能的一个唯一方面(aspect)所必需的所有东西。

说的简单一点,就是把程序进行封装(函数封装、面向对象、文件...)

OK,话不多说,让我们开始吧。

函数

什么是函数?

函数的英文单词为function, 我们将其翻译过来,就是“函数,功能”。

其实,函数就是一个具有特定功能的代码块。

函数的作用

函数的目的是封装,将特定功能的代码块进行封装,其目的是提高代码的重用性,从而提高开发效率,并且降低了后期的维护成本。

函数的定义和使用

函数的定义其实非常简单,我们用代码来写一下:

# 定义函数[基本结构]
def 函数名([参数列表]):
当前行数的具体功能的代码
当前行数的具体功能的代码
...

当然,函数在写完之后并不会自动执行,只是把函数定义了而已。如果想要使用定义完成的函数,需要用语法来进行函数的调用。

那么函数该如何调用呢?如下:

函数名()

示例:

# 函数的定义格式
def love():
print('i')
print('love')
print('u')

# 函数的调用
love()

当前程序运行输出结果:

i
love
u

以上代码可以得到函数的第一个特征:函数定义后,不调用不执行。还记得咱们上节课强调的流程控制吗?代码最基本流程顺序是自上而下的,所以,这个时候我们如果调用放在上方,例如:

# 函数的调用
love()

# 函数的定义格式
def love():
print('i')
print('love')
print('u')

此时因为love()调用的时候函数还未被定义,所以会执行报错:

NameError: name 'love' is not defined
raw-image

所以我们需要注意:不能在函数定义前调用函数。

另外,我们需要注意,函数的调用不受次数的影响,比如,我们定义好函数后,这个时候在后面调用三次:

love()
love()
love()

那执行后的结果应该是连着打印了三次结果。

和变量一样,函数的命名也是要遵守命名规范的:

  • 字母数字下划线,不能以数字开头
  • 严格区分大小写,且不能使用关键字
  • 命名最好有意义,且不要使用中文

现在我们想想,在love()函数被定义后,我们再来定义一个同名的函数会怎么样?

我们尝试一下,在刚才定义好的函数下方重复写一个同名的函数:

# 函数的定义格式
def love():
print('i')
print('love')
print('u')

def love():
print('u')
print("don't")
print('love')
print('me')

# 函数的调用
love()

直接结果:

u
don't
love
me

那,我们得到了实验结果:同样的函数名被再次定义之后,冲突的函数会被覆盖。

所以,最后我们总结一下函数的特征及注意事项:

1. 函数定义后,不调用不执行
2. 不能在函数定义前调用函数
3. 函数的调用不受次数影响
4. 函数的命名要遵守命名规范
- 字母数字下划线,不能以数字开头
- 严格区分大小写,不能使用关键字
- 命名最好有意义,且不要使用中文
5. 函数名不要冲突,冲突后会被覆盖

函数的参数

在定义函数的时候,我们需要注意给函数的参数。可以在参数列表的位置进行定义,这个称为形参。如果一个函数有形参,那么在调用的时候必须传递参数(实参)。实参将值传递给实参的过程,本质上就是变量赋值操作。

函数参数概念及分类

带有参数的函数,该如何定义?

在定义函数时,在小括号内可以定义形参(形式上的参数)

def love(w):
print(f'i love {w}')

# 调用带有形参的函数时,需要传递参数(实参)
love('马户')

执行结果为:

i love 马户

在这整个函数中,小括号内的w就是形参,在调用的时候的马户就是实参,在调用过程中将值传给了形参w

那么,如果我在调用的时候没有传递实参,就会直接报错:

love()

TypeError: love() missing 1 required positional argument: 'w'

形参可以是多个,这就是定义带有多个参数的函数:

def love(m, n):
print(f'{m} love {n}')

love('i', 'u')

执行结果:

1
i love u

如果形参是多个的话,那么有多少个形参就必须传递几个实参。并且参数都是按照顺序进行传递的。

如果少传一个参数,则同样会被错。

那,能不能多传呢?也不行,如果多传了参数,一样会报错。

至此,我们可以做如下总结:

  • 函数参数:调用时需要传递的数据
  • 函数参数的大类分为形参和实参
    • 形参意思:函数定义时的参数
    • 实参意思:函数调用时的参数
  • 形实关系:函数调用时,形参和实参个数需要一一对应

函数中的参数类型

在确定了什么是形参和实参之后,我们来看看,这两种参数都有哪些类型。

函数参数在类型上,包括:

  • 普通参数
  • 默认参数
  • 收集参数
  • 命名关键字参数
  • 关键字参数收集

普通参数

先来说说普通参数,其实就是位置参数,也叫顺序参数,也是必须传递的参数。

def love(m, n):
print(f'{m} love {n}')

love('i', 'u')

这段代码中,m, n就是普通参数,必须传递。

默认参数

有些函数在定义的时候,行参上就已经定义了默认值,那么这种就叫做默认参数。

在调用函数的时候,默认参数是可以不传值的。当传值之后,默认值就会被改变:

def func(x, y=20):
print(x, y)

func(2)

这段代码中的行参y就是默认参数,我们在调用函数func()只写了一个实参,也就是只传了一个值给函数。这个时候执行结果为:

2 20

我们修改一下,传两个值进去看看结果:

def func(x, y=20):
print(x, y)

func(2, 100)

执行结果:

2 100

可以看到,本来我们定义的行参y的默认值被改变了。

在定义默认参数的时候需要注意,函数中的默认参数只能全部定义在普通参数的后面,否则在调用函数的时候就会报错,比如以下这些情况:

# 第1种错误情况
def func(x=100, y=200, z):
print(x, y, z)

func(100,200,300)

# 第2种错误情况
def func(x=100, y, z=200):
print(x, y, z)

func(100, 200, 300)

# 第3种错误情况
def func(x, y=100, z):
print(x, y, z)

func(300,200,50)

收集参数

收集参数就是专门收集在函数调用时传递的多余的实参,或者我们可以理解为,不确定需要传递多少个实参,直接用一个行参来接收。

比如,我们现在有个需求就是需要计算用户输入的数字总和,我们按前面那个函数的定义方式为:

def func(x, y z=100):
print(x+y+z)

func(20,30)

这个函数中,我们输入2个值或者3个值都可以,但是当我们只输入一个值或者三个以上的时候,程序就会报错了。

那么有没有什么办法,不管用户输入多少个数字,我们都可以进行相加计算呢?

def func(x="+", *args):
if x == '+':
print('加法运算', args)
else:
print('减法运算', args)

func("-", 2, 3, 4, 5, 6, 7, 8)

这段代码执行结果为:

减法运算 (2, 3, 4, 5, 6, 7, 8)

虽然中间的运算代码我没有写,但是大家可以看到,已经可以接受不固定的多个参数了。

这个*args就是我们的收集参数。

在定义函数的时候,如果需要收集参数,那么这个形参前面需要加一个*号,例如*args。这里需要注意一点,*args并不是固定写法,你可以随意定义一个,只要前面有*号就可以了。比如下面这样:

def func(x="+", *y):
if x == '+':
print('加法运算', y)
else:
print('减法运算', y)

func("-", 2, 3, 4, 5, 6, 7, 8)

一样可以执行并得到一样的结果,这个时候,我们的*y就是收集参数。

收集参数也有两类,一种是普通的收集参数:专门用于收集多余的普通参数,形成一个新的元组。

语法: 参数前面加*, 例如:*args

还有一种是关键字收集参数:用于专门收集多余关键字实参,形成一个新的字典:

语法:参数前面加**, 例如:**kwargs

现在我们已经理解了普通的收集参数,那么在学习关键字收集参数之前,我们先来学习一下命名关键字参数

命名关键字参数

命名关键字是放在*号后面的参数,调用的时候强制必须传入制定参数名才能进行调用。

def func(a, b, c=3, *args, name):
print(a, b, c, "\n", *args, "\n", name)

func(1, 2, 3, 4, 5, 6, 7, 8, name='茶桁')

这段代码执行结果:

1 2 3 
4 5 6 7 8
茶桁

我们特意在中间加了换行字符来清晰的辨别*argsname

如果在这段代码中我稍微变一下,在执行函数的时候,实参里不标明name可以吗?

def func(a, b, c=3, *args, name):
print(a, b, c, "\n", *args, "\n", name)

func(1, 2, 3, 4, 5, 6, 7, 8, '茶桁')

执行之后我们收到了报错:

TypeError: func() missing 1 required keyword-only argument: 'name'

这段报错明显告诉我们,确实了一个必须的关键字参数name

那为什么会出现这种报错呢?这是因为在关键字参数之前,我们使用了*args来进行收集参数,那么无论你写多少,这些值都会被*args接收变成元组,那么后面的name自然就无法接受到值了。

让我们再来做一个实验,给命名关键字参数加上一个默认值,那么我们就能明显的看出问题:

def func(a, b, c=3, *args, name='_茶桁'):
print(a, b, c, "\n", *args, "\n", name)

func(1, 2, 3, 4, 5, 6, 7, 8, '茶桁')

这段代码执行结果:

1 2 3 
4 5 6 7 8 茶桁
_茶桁

可以看到,name给了默认值之后不再出现报错,而我们的实参也并未传到name里,而是全部被*args接收了。最后打印出了name的默认值_茶桁

利用命名参数的这种定义参数名称接收值的特点,我们就可以打乱之前普通参数传值的顺序性,比如:

def func(x, y):
print(x, "\t", y)

func(2, 3)
func(y = 2, x = 3)

执行结果为:

2 	 3
3 2

还是最开始的普通参数的写法,但是最后执行函数的时候,我们给实参指定了名称,这样传参顺序就没那么重要了。

所以,我们总结一下:

  • 关键字参数定义在收集参数后面
  • 关键字参数必须通过形参的名字来进行传递

关键字参数收集

前面我们在讲收集参数的结尾处提到了关键字参数收集,形式为**kwargs

def func(a, b, c=3, *args, name, age,  **kwargs):
print(a, b, c)
print(args) # 普通收集参数,会把多余的参数收集为元组
print(name, age)
print(kwargs) # 关键字参数收集,会把多余的关键字参数收集为字典

func(1, 2, 4, 112, 123, 321, 541, 231, name="茶桁", age=18, sex='male', height=185, x='x', y='y')

执行结果:

1 2 4
(112, 123, 321, 541, 231)
茶桁 18
{'sex': 'male', 'height': 185, 'x': 'x', 'y': 'y'}

从执行结果上我们可以看到,在nameage之后的所有参数都被传递到了**kwargs里,然后作为字典打印了出来。

在声明这个函数和执行函数的时候需要注意,这些参数都是有顺序的,如果在执行函数的时候再多传一个非关键字参数,这个时候程序就会报错,如果是关键字参数,则照样会被**kwargs接收。

在我们介绍完这些参数之后,我们最后再说明一下:

  • 形参声明的位置顺序:普通参数 -> 默认参数 -> 收集参数 -> 命名关键字参数 -> 关键字收集参数
  • def func(a, b, c=1, *args, d, **kw) 这段声明中,a,b为普通参数,c是默认参数,args是收集参数,d是命名关键字参数,kw是关键字收集参数
  • 极少情况下会同时出现上面五种形参,一般情况下为:普通参数,收集参数,关键字收集参数
  • 所有参数的摆放问题:
    • 实参:普通实参在前,关键字参数在后
    • 形参:关键字收集参数一定在最后出现,收集参数推荐在普通参数之后使用。
    • 推荐顺序:普通形参、收集参数、关键字收集参数

函数的返回值

一个函数除了可以完成一定功能之外,还可以用来安需要返回一些内容。在函数中,使用return关键字来制定返回数据,可以返回任意类型的数据。

函数的返回值会把数据返回到调用的地方,可以使用变量进行接收,或者作其他处理。

函数可以分为两类:

  1. 执行过程函数:函数体内完成一定的功能即可,没有返回值
  2. 具有返回值的函数:函数体内完成一定的功能,并且返回一个结果到函数调用处。

比如:

def func(a, b):
print(f'{a} love {b}')

以上函数就是一个没有返回值的函数,这个函数只是为了完成打印这句话的功能。

那么有返回值的函数是什么样子?

如果需要在函数中制定返回内容,我们需要使用return关键字。

# 有返回值的函数
def func(a, b):
res = f'{a} love {b}'
# 可以在函数体内,使用return返回内容
return res

r = func('老鼠', '布丁')
print(r)

执行结果为:

老鼠 love 布丁

在这段代码中,我们在func()的函数体内最后利用关键字return返回了任意内容,并且使用变量r接收了这个返回值,最后讲r打印了出来。

在调用func()这个函数的时候,函数中的返回值会返回到函数调用处。

我们再来研究一下return这个关键字,我们在return的前后都加上一段打印代码,看看会发生什么。

def func(a, b):
res = f'{a} love {b}'
print('这是return前')
return res
print('这是return后')

r = func('老鼠','布丁')
print(r)

执行结果:

这是return
老鼠 love 布丁

看到结果我们可以清楚,return之后的的代码并未继续执行,也就是说,我们如果要在函数体内执行其他任务,必须放在return之前执行,否则根本不会执行。那么我们可以得出结论:return必须放在一个定义函数的最后面。

Tips: 其实,即便没有return关键字或者returen之后没有任何内容,也有返回值,只是返回的是None值。

None是一个特殊的数据,表示什么都没有。查询类型可以看到返回 <class ‘NoneType’>

变量的作用域

作用域就是当前起作用,可用的范围区域。也就是变量的有效范围。

变量按作用域可以分为:

  • 局部变量: 在函数内部可以使用的变量
  • 全局变量:在函数内外都可以使用的变量

局部变量

让我们尝试下,如果函数内定义的变量在函数外使用会如何:

def func():
a = 20

print(a)

执行结果:

NameError: name 'a' is not defined

被告知a并未被定义。

可以看到,函数内定义的变量,在函数外连获取都做不到。这种在函数内定义的这个变量a,就是局部变量,它在函数外不能使用。

再让我们来看看将变量定义在函数外会是怎样的一种情况:

num = 10
def func():
print(num)

func()

执行结果:

10

func函数内,我们获取到了变量num的值并打印。那说明,在函数内我们可以获取函数外部的变量。

我们继续在继续看:

num = 10
def func():
num += 20
print(num)

func()

执行后报错:

UnboundLocalError: local variable 'num' referenced before assignment

这样我们可以看到,变量num在函数内虽然可以使用,但是无法进行更改。

那在函数外定义的所有变量都是如此吗?再让我们试试看:

items = [1, 2, 3, 4, 5]
def func():
items[0] = 20
print(items)

func()

执行结果:

[20, 2, 3, 4, 5]

由此我们看出,并不是所有的变量都不可在函数内进行更改。

其实,变量是分为两种的:

  • 可变数据类型:在函数外定义的变量,在函数内可以使用并改变
  • 不可变数据类型:在函数外定义的变量,在函数内只可以访问而无法改变

可变数据类型有列表和字典,其他的都是不可变数据类型。

全局变量

之前我们介绍的都是局部变量,那怎样定义全局变量呢?

在函数内使用global直接定义的变量,就是全局变量,函数内外都可以直接使用。

在函数外定义的变量,在函数内使用global关键字进行声明,那么也是全局变量。

例如:

num = 20
def func():
global num
num += 10
print(num)

func()

这个时候我们可以得到执行结果:

30

那有小伙伴就问了,如果我在函数外直接使用global定义全局变量可以吗?让我们来试试看就知道了:

global num
num = 20
def func():
num += 10
print(num)

func()

执行之后得到报错:

UnboundLocalError: local variable 'num' referenced before assignment

这样我们就得到了结果:不可以。

在整个程序中,我们可以使用两个函数方法来获取数据:

globals()用来获取全局数据,locals()用来获取当前作用域的数据

讲到这里,我们再来多看一组代码:

# 函数的作用域
def outer():
print('this is outer func...')
def inner():
print('this is inner func...')

outer()
inner()

这段代码执行结果为:

this is outer func...

NameError: name 'inner' is not defined

正常执行了outer()内的打印,然后又报了一个错误,提示inner函数未定义。

说明,不只是变量有作用域,函数一样也有作用域。要想inner函数内的打印也起作用,我们需要在函数内就调用执行inner()

# 函数的作用域
def outer():
print('this is outer func...')
def inner():
print('this is inner func...')
inner() # 在函数内执行
outer()

这样,我们执行的结果就是:

this is outer func...
this is inner func...

如果我们在外层函数中定义一个局部变量,能在内层函数中使用吗?

# 函数的作用域
def outer():
a = 2
print('this is outer func...')
def inner():
a += 1
print('this is inner func...')
print(a)
inner()
outer()

执行之后得到报错:

UnboundLocalError: local variable 'a' referenced before assignment

说明并不可以。

nonlocal关键字

那么,到底有没有什么办法在内函数中使用上一层函数中的局部变量呢?答案是有办法。

在内函数中如果想要使用外层函数的变量,那么需要使用nonlocal关键字,可以引用上一层函数中定义的局部变量。

# 定义一个外层函数
def outer():
# 外函数的局部变量
num = 10
# 内函数, 局部函数, 在函数的内部定义的函数
def inner():
# nonlocal 关键字在局部函数中使用
nonlocal num # 可以引用上一层函数中定义的局部变量
num += 1
print(num)
inner()
outer()

执行后返回结果:

11

至此,我们通过使用nonlocal关键字,成功拿到了外层函数定义的变量num并使用。最后打印出使用的结果。

这里我们要注意,nonlocal虽然可以引用上一层函数中定义的局部变量,但是这并不代表提升为了全局变量。

既然我们有了global关键字可以提升变量为全局变量,为什么还需要一个nonlocal关键字呢?是不是有点多此一举?

这两者的功能上并不相同。global关键字修饰变量后标识该变量是全局变量,对该变量进行修改就是修改全局变量,而nonlocal关键字修饰变量后标识该变量是上一级函数中的局部变量,如果上一级函数中不存在该局部变量,nonlocal位置会发生错误(最上层的函数使用nonlocal修饰变量必定会报错)。

关于函数的文档

我们在一个未定义任何变量和函数的空白文档中打印一下全局数据:

print(globals())

执行结果:

{
'__name__': '__main__',
'__doc__': 'Automatically created module for IPython interactive environment',
'__package__': None,
'__loader__': None,
'__spec__': None,
'__builtin__': <module 'builtins' (built-in)>,
'__builtins__': <module 'builtins' (built-in)>,
'_ih': ['',
'print(globals())'],
'_oh': {},
'_dh': [PosixPath('/Users/xx/git/AI_Cheats/Python'),
PosixPath('/Users/xx/git/AI_Cheats/Python')],
'In': ['',
'print(globals())'],
'Out': {},
'get_ipython': <bound method InteractiveShell.get_ipython of <ipykernel.zmqshell.ZMQInteractiveShell object at 0x105be29e0>>,
'exit': <IPython.core.autocall.ZMQExitAutocall object at 0x105be3280>,
'quit': <IPython.core.autocall.ZMQExitAutocall object at 0x105be3280>,
'open': <function open at 0x10488e710>,
'_': '',
'__': '',
'___': '',
'__vsc_ipynb_file__': '/Users/xx/git/AI_Cheats/Python/globals.ipynb',
'_i': '',
'_ii': '',
'_iii': '',
'_i1': 'print(globals())'
}

我们来看这个打印出来的json

类似于__name__这种前后有__ __的数据,称之为“魔术变量”。我们并未定义,但是已经存在了。

如果脚本作为主程序,那么__name__值是__main__,如果是当作一个模块在另外一个脚本中引用去使用,那么值就是当前文件的命名。

__doc__当前脚本的文档说明,在当前脚本当中的第一个三引号注释就是当前脚本的说明文档。比如,我们在这个空白的文档中写一段三引号注释

"""
这里是整个文档的说明部分。
"""
def func():
pass

然后我们直接将doc打印出来查看:

print(__doc__)

可以看到输出内容:

这里是整个文档的说明部分。

这种文档其实不止适用于python文件,对于定义的函数依然适用。比如,我们执行定义了一个函数,并在函数内部用三引号进行注释:

def func():
"""
这里是让你写一写函数的文档说明的。
需要说明当前函数的作用,
如果当前函数还有形参,那么也需要对形参进行一一说明。
name: 这个是一个name参数,用于接收姓名
age: 这个参数是表示年龄
:return: 此处说明当前函数的返回值
"""
pass

这个时候,我们在下方执行:

print(func.__doc__)

可以看到我们在注释内定义的说明文档被打印出来了:

raw-image

这样,我们不仅可以在自己写函数的时候在上方清晰的写明当前函数的作用及参数,我们还可以使用此方法,查找其他人所写的函数的一些说明。

在我们平时写代码的时候,养成一个好习惯是非常有必要的。

总结一下:

  • print(__name__): 获取当前脚本的文件名
  • print(__doc__): 获取当前脚本的说明文档
  • print(func.__doc__): 获取当前函数的说明文档

练习:函数封装

我们上一讲中的练习中,我们打印了乘法表,矩形图形,还计算了12生肖。这里我们就将乘法表来封装成函数,实现我们上节课留的其中一道思考题:反向打印。

我们先将打印乘法表封装起来:

# 定义函数,打印九九乘法表
def multiply_table():
"""
当前函数的功能是打印出乘法表
"""
for x in range(1, 10):
for y in range(1, x+1):
print(f'{x}X{y}={x*y}', end=" ")
print()

这样,我们在其他地方执行multiply_table()函数的时候,就可以直接打印出乘法表。

现在让我们给这个函数多加一些功能:

# 定义函数,打印九九乘法表
def multiply_table(i=0):
"""
当前函数的功能是打印出乘法表
i=0; i 这个参数可以用来控制正向输出和方向输出,0的时候正向,1的时候反向,默认为0
"""
if i:
rs = range(9, 0, -1)
else:
rs = range(1, 10)

for x in rs:
for y in range(1, x+1):
print(f'{x}X{y}={x*y}', end=" ")
print()

我们执行函数的时候,输入1来试试看:

multiply_table(1)

输出结果:

9X1=9 9X2=18 9X3=27 9X4=36 9X5=45 9X6=54 9X7=63 9X8=72 9X9=81 
8X1=8 8X2=16 8X3=24 8X4=32 8X5=40 8X6=48 8X7=56 8X8=64
7X1=7 7X2=14 7X3=21 7X4=28 7X5=35 7X6=42 7X7=49
6X1=6 6X2=12 6X3=18 6X4=24 6X5=30 6X6=36
5X1=5 5X2=10 5X3=15 5X4=20 5X5=25
4X1=4 4X2=8 4X3=12 4X4=16
3X1=3 3X2=6 3X3=9
2X1=2 2X2=4
1X1=1

可以看到,我们的控制结果被成功打印出来。至此,这个有一些小功能的九九乘法表就封装完成了。

那么这一节课就不留思考题了,大家课后熟练掌握一下封装函数和变量的作用域,我们下节课来学习一些高阶函数, 好,下课。

茶桁
茶桁
80后,先后在多家大厂担任数据产品经理,中台产品。
留言0
查看全部
發表第一個留言支持創作者!