头图

本文首发于公众号:Hunter后端

原文链接:Python面试必备二之 lambda 表达式、函数传参 args 和 kwargs、垃圾回收机制和上下文管理器

本篇笔记主要介绍 Python 面试过程中关于 Lambda 表达式、函数传参、垃圾回收机制等问题,大致如下:

  1. Python 中 Lambda 表达式是什么,有什么用
  2. 函数的参数 args 怎么用,kwargs 怎么用
  3. Python 怎么进行垃圾回收,都有哪几种类型
  4. Python 上下文是什么,怎么定义上下文

以下是本篇笔记目录:

  1. Lambda 表达式
  2. 函数传参 args 和 kwargs
  3. 垃圾回收机制
  4. 上下文管理器

1、Lambda 表达式

Lambda 表达式,即 Lambda 函数,是一个匿名函数,也就是说我们可以创建一个不需要定义函数名的函数。

1. Lambda 函数的定义和调用

比如对于下面的两数相加的函数:

def add(x, y):
    return x + y

我们可以使用 lambda 函数表示如下:

add_lambda = lambda x, y: x + y

对于 add 函数和 add_lambda 匿名函数,这两个函数的效果是一致的,都是对于输入的两个参数进行相加,然后返回:

print(add(1, 3))

print(add_lambda(1, 3))

Lambda 函数的定义方式其实很简单:

lambda x, y: x + y

使用 lambda 修饰,表示定义一个函数,之后跟着的 x 和 y 表示输入的参数,冒号 : 后跟着的即为需要 return 的函数逻辑,这里是相加。

2. Lambda 函数的使用

除了前面直接调用的使用场景,Lambda 还有一个比较常用的场景,就是用在 Python 的内置函数中,比如 map,sorted 等。

下面以两个示例来介绍 Lambda 的使用。

1) 获取两个列表对应位置之和

给定两个列表,获取这两个列表对应索引位置的和,形成一个新列表返回,这里使用 Python 的 map 函数和 Lambda:

a = [1, 2, 3]
b = [4, 5, 6]

c = list(map(lambda x, y: x + y, a, b))
print(c)
# [5, 7, 9]

这里使用的 Lambda 函数接收两个参数,其来源是 a 和 b 两个列表。

2) 对字典列表根据指定 key 排序

我们有一个字典列表如下:

s = [{"a": 6}, {"a": 19}, {"a": 3}, {"a": 7}]

如果我们想要对其根据元素的 a 这个 key 的值进行从小到大进行排序,其操作如下:

sort_s = sorted(s, key=lambda x: x["a"])
print(sort_s)
# [{'a': 3}, {'a': 6}, {'a': 7}, {'a': 19}]

2、函数传参 args 和 kwargs

当我们定义一个函数的时候,需要为其指定参数,比如一个两数相加的函数示例如下:

def add(x, y):
    return x + y

而如果我们想要实现一个函数,可能会传入两个数,也可能传入三个、四个,甚至七八个数字,然后返回其和,这个能实现吗?

可以,这个就需要用到我们的可变参数了,也就是这里的 args 和 kwargs。

1. args

在定义函数中,如果我们不确定会传入多少个参数,我们可以使用 args 这个可变参数,它的作用是将不定量的参数按照顺序以 tuple 元组传入,在函数的定义中,前面加一个 * 号即可完成定义。

比如我们想实现一个不定量数字相加然后返回的函数:

def add_n(*args):
    print("参数 args 内容为:", args)
    total = 0
    for num in args:
        total += num
    return total

print(add_n(1, 2))
print(add_n(1, 2, 3, 4))
print(add_n(1, 2, 3, 4, 5, 6, 7))

当然,对于这个不定量参数,如果我们想要对其截取特定数量,也可以如下实现:

a, b, c, *_ = args

2. kwargs

kwargs 也是以可变参数的形式传入,不过不一样的点在于函数是将任意个关键字参数放入一个 dict 进行处理的,其使用方式是在 kwargs 前加两个 **

比如我们想要实现一个函数用于计算,计算到底是加法还是减法需要根据传入的符号来确定,我们可以实现如下:

def calculate(a, b, **kwargs):
    print("参数 kwargs 内容为:", kwargs)
    if kwargs.get("add") is True:
        return a + b
    elif kwargs.get("sub") is True:
        return a - b
    else:
        print("不合法的运算符")
        return None
        
print(calculate(10, 5, add=True))
print(calculate(10, 5, sub=True))
print(calculate(10, 5, times=True))

可以看到 kwargs 的输出内容为:{'add': True}

3. args 和 kwargs 混合使用

这里介绍一下如何将固定参数和可变参数 args 以及 kwargs 混合在一起使用的示例。

以下函数示例没有实际意义,仅为了展示如何使用参数:

def test_arg(a, b, *args, **kwargs):
    print("f固定参数为 a: {a}, b: {b}")
    print(f"不定量参数为 args: {args}")
    print(f"不定关键字参数为 kwargs: {kwargs}")

arg_list = [1, 2, 3]
kwarg_dict = {"x": 5, "y": 4}

a = 1
b = 2
test_arg(a, b)
test_arg(a, b, *arg_list)
test_arg(a, b, **kwarg_dict)
test_arg(a, b, *arg_list, **kwarg_dict)
test_arg(a, b, *arg_list, z=3, **kwarg_dict)

注意:这里需要注意的一点是,当我们传入列表或者字典作为参数传入时,如果不加上 *** 作为修饰,那么函数会将作为一个整体输入,而非不定量参数:

test_arg(a, b, arg_list)
# 这里输入的参数的值就是 a, b, [1, 2, 3]
# 而不是 a, b, 1, 2, 3

注意:函数里的 argskwargs 只是作为常用的写法,而不是并非固定的关键字,我们也可以写成 func(*arg_list, **kwarg_dict),在函数内部调用的时候分别替换一下即可。

4. 用于装饰器

不定量参数 args 和 kwargs 还有一个比较常见的场景就是用于装饰器,比如下面的示例:

def decorator_func(func):
    def inner_func(*args, **kwargs):
        result = func(*args, **kwargs)
        return result
    return inner_func

关于装饰器更多详细内容,参见:闭包与装饰器

3、垃圾回收机制

Python 的垃圾回收机制有三种,一种是引用计数,一种是标记清除,一种是分代回收。

1. 引用计数

引用计数机制是通过记录一个对象被引用的次数来管理内存的机制,当这个对象的引用次数为 0,那么该对象则会被销毁。

那么引用计数的规则是怎么样的呢?

1) 引用计数规则

a. 引用计数增加规则

先来介绍一下引用计数的增加规则,当一个对象发生以下行为时,该对象的引用计数器 +1:

  1. 对象被创建
  2. 对象被引用
  3. 对象被作为参数传给函数
  4. 对象作为一个元素,存储在容器中(这里的容器比如说有列表,字典等)

我们可以使用 sys.getrefcount() 函数来查看某个变量指向的对象被引用的次数。

注意:当使用这个函数查看被引用次数的时候,总是比实际的引用次数要 +1,因为作为参数传给 sys.getrefcount() 的时候,引用次数也会 +1。

下面是示例:

import sys
# 987 这个对象被创建,次数 +1
a = 987
print(sys.getrefcount(a))  # 2

# 对象被引用,次数 +1
b = a
print(sys.getrefcount(a))  # 3

def test(a):
    print(sys.getrefcount(a))

# 对象被传递给函数,次数 +1
test(a)  # 4

# 对象作为元素存储在元素中,次数 +1
c = [a]
print(sys.getrefcount(a))  # 4

这里需要注意一下,虽然调用了函数 test(a) 增加了一次引用计数,但是在函数执行完之后,内部的变量被销毁,所以其中增加的引用计数会消失。

b. 引用计数减少规则

前面介绍的是引用计数增加的规则,那么与上述情况相对应的反向操作则会使得引用计数器 -1:

  1. 对象的别名被显式销毁, del a
  2. 对象的引用别名被赋予新的对象时, a = 99999
  3. 对象离开它的作用域时,比如函数执行完毕
  4. 将该元素从容器中删除或者容器直接被销毁时

以下是示例:

当对象的别名被销毁时:

a = 99999
b = a
print(sys.getrefcount(a))  # 3

del b
print(sys.getrefcount(a)) # 2

当对象的别名被赋予新的对象时:

a = 99999
b = a
print(sys.getrefcount(a)) # 3

b = 100000
print(sys.getrefcount(a)) # 2

当元素被从容器中删除或者容器销毁时:

a = 99999
c = [a]
print(sys.getrefcount(a))  # 3

c.remove(a)
print(sys.getrefcount(a)) # 2


a = 99999
c = [a]
print(sys.getrefcount(a)) # 3

del c
print(sys.getrefcount(a)) # 2

还有一个当调用这个对象的函数执行完毕之后,它的引用次数也会 -1,这一点在前面增加规则里也已经说明了。

在我们执行程序的过程中,引用计数根据上面的增减规则进行计数,当某个对象的引用次数变成了 0,比如我们执行了对某个变量进行了新的赋值,且原对象没有其他引用,或者使用了 del 删除了引用等方式造成了这个对象的引用计数变成了 0,那么它的内存则会被回收释放。

c. 对象池和常量缓存

这里需要注意一点,前面的示例中,我对 a 进行赋值的时候,取的都是很大的整数,比如 99999,100000 这种,为什么呢。

因为在 Python 中,对于整数、字符串这种不可变对象,会有一些常见的小整数,以及某些短字符串,比如 -5-256 之间的整数,Python 解释器启动的时候就对其进行了缓存,并且在解释器的整个生命周期中重用,所以当我们执行 a=1 的操作的时候,并不是创建一个新的整数对象,而是引用的已经存在的整数 1 对象。

可以看下面这个示例:

a = 256
print(sys.getrefcount(a))

a = 257
print(sys.getrefcount(a))

2. 标记-清除

前面介绍的引用计数机制有一个无法解决的一种的问题,就是循环引用。

1) 循环引用

什么叫循环引用呢?

就是对象间互相引用,这种情况下,对象的引用次数并不为 0,但是没有任何外部的引用指向他们,这种情况下,即便是删除了引用,也无法通过引用计数机制对其进行回收。

以下是一个示例:

首先,创建两个循环引用的对象:

class Node:
    def __init__(self, value):
        self.value = value
        self.next = None

a = Node(1)
b = Node(2)

a.next = b
b.next = a

然后查看未被垃圾回收的对象里是否有这两个对象:

import gc
target_list = [obj for obj in gc.get_objects() if isinstance(obj, Node)]
for target in target_list:
    print(target.value)
# 1
# 2

这里可以看到还是有这两个对象的。

然后我们对其进行删除操作:

del a
del b

然后再次获取未被垃圾回收的对象里是否还有这两个对象,这里我们可以打印出其引用对象的 value值:

import gc
target_list = [obj for obj in gc.get_objects() if isinstance(obj, Node)]
for target in target_list:
    print(target.value, gc.get_referents(target)[1].value)
# 1 2
# 2 1

可以看到,即便是我们删除了这两个对象,但是因为其内部的互相引用,导致其并没有被回收释放。

2) 标记-清除

那么如何解决上面这个循环引用的问题呢,这里就引入了 Python 进行垃圾回收的另一个机制,标记-清除。

标记-清除分为两个阶段,一个是标记,一个是清除。

在标记阶段,系统会从根节点对象出发,遍历所有对象,所有可以访问到的对象(也就是还有对象引用它)会被打上一个标记,表示这个对象是可达的。

这里的根节点对象,指的是全局变量、调用栈、内存器。

在清除阶段,遍历所有对象,将上一步中未被标记的不可达对象进行回收。

3. 分代回收

在执行垃圾回收的过程中,程序会被暂停,为了减少程序暂停的时间,采用分带回收的机制来降低垃圾回收的耗时。

所谓分代回收就是将程序里的对象分为三个世代,每个世代里的对象会有不同的时间间隔进行检测。

在 Python 中,一共 3 种世代,G0,G1 和 G2,其中,G0 包含新创建的对象,最频繁的进行垃圾回收,G1 则包含在 G0 中幸存下来,也就是没有被回收的对象,G2 则包括在 G1 中没有被回收的对象,这几个世代进行垃圾回收的频率逐个降低。

每一个世代执行垃圾回收的机制是当每个世代的对象数量达到一定的阈值则开始进行当前世代的垃圾回收操作,通过下面的命令我们可以获取到每个世代执行垃圾回收的对象数量阈值:

import gc
gc.get_threshold()
(700, 10, 10)

表示 G0 世代的对象数量阈值是 700,达到 700 后,则会开启垃圾回收操作,第二、三个结果则表示的是 G1、G2 世代的阈值。

这个阈值我们也可以手动设置:

gc.set_threshold(800, 20, 10)

gc.get_threshold()

4. 手动执行垃圾回收

手动执行垃圾回收的操作如下:

import gc
gc.collect()

如果想要指定某个世代进行回收:

gc.collect(0)
gc.collect(1)
gc.collect(2)

4、上下文管理器

上下文管理器是一个对象,它定义了进入和退出特定上下文的标准方式,它的主要作用是管理资源,确保在使用资源时对其进行正确地分配和释放。

以常用的打开文件为示例,使用 with 来操作上下文管理器:

with open("./test.txt", "r") as f:
    data = f.read()

在这个过程中,open() 函数就返回了一个文件对象,这个对象实现了上下文管理器协议,因此我们不用手动对其进行 close() 关闭文件的操作。

这样的操作可以使代码简洁,提高代码的可读性,以及减少因忘记释放资源而可能导致的资源泄露问题。

1. 手动实现上下文管理器

在 Python 中,只要一个类实现了 __enter____exit__ 方法,它的实例就是一个上下文管理器,其中,__enter__ 方法定义了进入 with 前执行的逻辑,__exit__ 方法则定义了退出 with 块时调用的逻辑。

我们可以手动来实现一个上下文管理器。

class ReadFile:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode
        self.file = None

    def __enter__(self):
        print("before read file")
        self.file = open(self.filename, self.mode)
        return self.file
    
    def __exit__(self, exc_type, exc_value, exc_tb):
        if self.file:
            self.file.close()
        print("exit read file")

        
with ReadFile("/path/to/file.txt", "r") as f:
    content = f.read()
    print(content)

为了展示上下文管理器的作用,我们可以对这个读取文件内容的上下文管理进行一个加强操作,比如读取文件内容的时候出错,或者文件不存在的时候不报错,而是以打印信息的形式输出,并且在这种出错的情况下,文件仍然可以正常关闭:

import os 

class ReadFile:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode
        self.file = None

    def __enter__(self):
        if not os.path.exists(self.filename):
            self.file = None
        else:
            self.file = open(self.filename, self.mode)
        return self

    def read(self):
        1 / 0
        return self.file.read()            

    def __exit__(self, exc_type, exc_value, exc_tb):
        if exc_value:
            print(exc_value)
        
        if self.file:
            print("file closed")
            self.file.close()
        return True

with ReadFile("/path/to/file", "r") as f:
    content = f.read()

在上面这个示例中,虽然在读取文件内容的时候报错了,但是却没有将错误 raise 出来,而是以打印的方式将错误信息暴露出来,而且文件也被正常关闭了。

2. 装饰器实现

Python 提供了一个装饰器,可以让我们不新建类而是以创建函数的方式来实现一个上下文管理器。

以下是一个示例:

from contextlib import contextmanager

@contextmanager
def open_file(filename, mode):
    f = open(filename, mode)
    
    yield f
    
    f.close()

with open_file("/path/to/file", "r") as f:
    content = f.read()
    print(content)

在上面这个示例中,使用 yield 将代码分为两部分,上半部分属于在类里 __enter__ 方法的操作,下半部分属于类里 __exit__ 方法的操作。

而如果想要对错误进行捕获并正确地释放资源,我们需要额外添加 try-except 操作:

from contextlib import contextmanager

@contextmanager
def open_file(filename, mode):
    f = open(filename, mode)
    try:
        yield f
    except:
        f.close()

如果想获取更多后端相关文章,可扫码关注阅读:


Hunter
27 声望12 粉丝