MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Python按值调用与按引用调用对比

2022-11-237.8k 阅读

Python中的变量本质

在深入探讨Python的按值调用和按引用调用之前,我们先来理解一下Python中变量的本质。

在Python里,变量并不是直接存储数据值,而是充当一个标签或者引用,指向内存中存储数据的对象。例如:

a = 10

这里的 a 并不是把值 10 直接存放在 a 这个变量的空间里,而是在内存中创建了一个代表整数 10 的对象,然后 a 作为一个引用指向了这个对象。

Python中的对象分为可变(mutable)和不可变(immutable)两种类型。不可变对象有整数(int)、浮点数(float)、字符串(str)、元组(tuple)等;可变对象包括列表(list)、字典(dict)、集合(set)等。

按值调用的概念

按值调用(Call by value)是一种函数参数传递的方式,在这种方式下,函数接收的是参数值的副本。也就是说,当调用函数时,会在函数内部为每个参数创建一个新的存储位置,并将调用者提供的参数值复制到这些新位置中。

按值调用在Python中的表现(不可变对象)

当传递不可变对象(如整数、字符串、元组)作为参数时,Python的行为类似于按值调用。

def modify_number(num):
    num = num + 1
    return num


original_num = 5
result = modify_number(original_num)
print(f"原始值: {original_num}, 函数返回值: {result}")

在上述代码中,original_num 是一个整数对象,当调用 modify_number 函数时,num 接收的是 original_num 值的副本。在函数内部对 num 的修改,比如 num = num + 1,只是在函数内部的 num 这个副本上进行操作,并不会影响到函数外部的 original_num。所以输出结果为:

原始值: 5, 函数返回值: 6

这里Python的行为和传统按值调用的概念是相符的,函数内部对参数的操作不会影响到函数外部的变量,因为传递的是值的副本。

深入理解不可变对象按值调用的本质

从内存角度来看,当执行 a = 10 时,内存中创建了一个代表 10 的对象,a 引用指向这个对象。当调用 modify_number 函数并传递 a 时,函数内部的 num 也指向了这个 10 的对象,就像下面这样:

按值调用内存示意图1

当执行 num = num + 1 时,由于整数是不可变对象,不能直接修改对象的值,而是在内存中创建了一个新的代表 11 的对象,num 重新指向了这个新对象,而 a 依然指向原来的 10 的对象。

按值调用内存示意图2

这就是为什么在函数外部 a 的值没有改变,体现了按值调用的特性。

按引用调用的概念

按引用调用(Call by reference)是另一种函数参数传递方式。在按引用调用中,函数接收的是参数的内存地址(引用),而不是值的副本。这意味着函数内部对参数的任何修改,都会直接影响到函数外部的原始变量。

按引用调用在Python中的表现(可变对象)

当传递可变对象(如列表、字典、集合)作为参数时,Python的行为类似于按引用调用。

def modify_list(lst):
    lst.append(4)
    return lst


original_list = [1, 2, 3]
result_list = modify_list(original_list)
print(f"原始列表: {original_list}, 函数返回列表: {result_list}")

在这个例子中,original_list 是一个列表对象,当调用 modify_list 函数时,lst 接收的是 original_list 的引用,也就是指向同一个列表对象。在函数内部通过 lst.append(4) 修改列表,实际上修改的就是 original_list 所指向的那个列表对象。所以输出结果为:

原始列表: [1, 2, 3, 4], 函数返回列表: [1, 2, 3, 4]

这里Python的行为类似于按引用调用,函数内部对参数的操作影响到了函数外部的变量。

深入理解可变对象按引用调用的本质

同样从内存角度分析,当执行 original_list = [1, 2, 3] 时,内存中创建了一个列表对象,original_list 引用指向这个对象。当调用 modify_list 函数并传递 original_list 时,函数内部的 lst 也指向了这个列表对象,如下所示:

按引用调用内存示意图1

当执行 lst.append(4) 时,由于列表是可变对象,可以直接在原对象上进行修改,所以修改后的列表对象内容变为 [1, 2, 3, 4]original_listlst 依然指向这个修改后的对象。

按引用调用内存示意图2

这就解释了为什么函数内部对 lst 的修改会影响到函数外部的 original_list,表现出按引用调用的特性。

Python并非严格的按值或按引用调用

虽然Python在传递不可变对象时类似按值调用,传递可变对象时类似按引用调用,但严格来说,Python既不是纯粹的按值调用,也不是纯粹的按引用调用,而是采用了一种称为“按对象引用传递”(Call by object reference)的方式。

“按对象引用传递”的具体含义

在Python中,所有的变量都是对象的引用。当将一个变量作为参数传递给函数时,传递的是这个对象引用的副本。对于不可变对象,由于对象本身不可修改,所以函数内部对参数的操作看起来像是按值调用,因为不会影响到外部变量所指向的对象。而对于可变对象,函数内部可以通过引用直接修改对象,这看起来像是按引用调用。

def change_reference(lst):
    new_list = [4, 5, 6]
    lst = new_list
    return lst


original_list = [1, 2, 3]
result = change_reference(original_list)
print(f"原始列表: {original_list}, 函数返回列表: {result}")

在这个例子中,original_list 是一个列表对象,传递给 change_reference 函数时,lst 获得了 original_list 引用的副本。在函数内部,new_list 创建了一个新的列表对象,然后 lst = new_list 使得 lst 指向了这个新对象。但此时 original_list 依然指向原来的 [1, 2, 3] 列表对象。所以输出结果为:

原始列表: [1, 2, 3], 函数返回列表: [4, 5, 6]

这里虽然 lst 改变了指向,但并没有影响到 original_list 的指向,这和传统按引用调用有所不同。如果是严格的按引用调用,original_list 也应该指向新的 [4, 5, 6] 列表对象。

与其他语言按值和按引用调用的对比

以C++为例,C++ 中可以明确指定按值传递和按引用传递。

#include <iostream>
#include <vector>

// 按值传递
void modify_number_value(int num) {
    num = num + 1;
}

// 按引用传递
void modify_vector_reference(std::vector<int>& vec) {
    vec.push_back(4);
}

int main() {
    int original_num = 5;
    modify_number_value(original_num);
    std::cout << "原始值: " << original_num << std::endl;

    std::vector<int> original_vector = {1, 2, 3};
    modify_vector_reference(original_vector);
    std::cout << "原始向量: ";
    for (int num : original_vector) {
        std::cout << num << " ";
    }
    std::cout << std::endl;

    return 0;
}

在C++ 中,按值传递 int 类型参数时,函数内部对参数的修改不会影响外部变量;而按引用传递 std::vector 类型参数时,函数内部对参数的修改会直接影响到外部变量。

相比之下,Python统一采用按对象引用传递的方式,虽然在某些情况下表现出类似按值或按引用调用的特性,但本质上有其独特之处。

按值调用与按引用调用在实际编程中的影响

不可变对象按值调用的影响

由于不可变对象按值调用,在函数内部对参数的操作不会影响到外部变量,这使得代码的行为更加可预测。例如在多线程编程中,如果传递的是不可变对象作为参数,一个线程对参数的操作不会干扰到其他线程中该变量的值,提高了程序的稳定性和安全性。

import threading


def increment_number(num):
    num = num + 1
    print(f"线程内修改后的值: {num}")


original_num = 10
thread1 = threading.Thread(target=increment_number, args=(original_num,))
thread2 = threading.Thread(target=increment_number, args=(original_num,))

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print(f"最终原始值: {original_num}")

在这个多线程示例中,两个线程都对 original_num 的副本进行操作,不会互相干扰,original_num 的最终值保持不变。

可变对象按引用调用的影响

可变对象按引用调用在某些情况下可以方便地实现数据共享和修改。比如在一个程序中,多个函数需要对同一个列表进行操作,通过按引用调用可以避免多次复制列表,提高效率。

def add_to_list(lst, value):
    lst.append(value)


def multiply_list(lst, factor):
    for i in range(len(lst)):
        lst[i] = lst[i] * factor


original_list = [1, 2, 3]
add_to_list(original_list, 4)
multiply_list(original_list, 2)
print(f"最终列表: {original_list}")

在这个例子中,add_to_listmultiply_list 函数通过按引用调用操作同一个列表,实现了对列表的连续修改。然而,这种方式也可能带来一些问题,比如函数之间的耦合度增加,如果一个函数意外修改了列表,可能会影响到其他依赖该列表的函数的行为,增加了调试的难度。

理解按对象引用传递的优势

Python的按对象引用传递方式结合了按值调用和按引用调用的优点。对于不可变对象,提供了数据的安全性和可预测性;对于可变对象,提供了高效的数据共享和修改能力。同时,这种统一的传递方式也使得Python代码更加简洁,不需要像C++那样显式指定参数传递方式,降低了编程的复杂度。

例如,在编写函数库时,Python开发者不需要为不同类型的参数分别考虑按值还是按引用传递,只需要根据对象的可变与不可变特性来设计函数行为,提高了代码的通用性和可维护性。

避免按引用调用可能带来的问题

虽然可变对象按引用调用有其优势,但也可能带来一些问题,比如意外修改共享数据。为了避免这些问题,可以采用以下几种方法。

传递副本

可以在函数调用时传递可变对象的副本,这样函数内部对副本的修改就不会影响到原始对象。

def modify_list_copy(lst):
    new_lst = lst.copy()
    new_lst.append(4)
    return new_lst


original_list = [1, 2, 3]
result = modify_list_copy(original_list)
print(f"原始列表: {original_list}, 函数返回列表: {result}")

在这个例子中,modify_list_copy 函数接收 original_list 的副本,对副本的修改不会影响到 original_list

使用不可变数据结构

在一些情况下,可以使用不可变数据结构来替代可变数据结构。例如,使用 frozenset 替代 set,使用 collections.namedtuple 替代字典。不可变数据结构可以确保数据的安全性,避免意外修改。

from collections import namedtuple


Person = namedtuple('Person', ['name', 'age'])
person = Person('Alice', 30)
# 以下代码会报错,因为namedtuple是不可变的
# person.age = 31

这里 Person 是一个不可变的 namedtuple,无法直接修改其属性,保证了数据的稳定性。

明确函数的副作用

在编写函数文档字符串时,明确指出函数是否会对传入的可变对象产生副作用,即是否会修改传入的对象。这样其他开发者在使用函数时就能清楚地知道函数的行为。

def add_to_list_with_doc(lst, value):
    """
    向列表中添加一个值。

    :param lst: 要操作的列表
    :param value: 要添加的值
    :return: 无返回值,直接修改传入的列表
    """
    lst.append(value)

通过这种方式,其他开发者在调用 add_to_list_with_doc 函数时就知道该函数会修改传入的列表。

按值调用与按引用调用在函数返回值中的体现

不可变对象返回值

当函数返回不可变对象时,返回的是对象的副本(从概念上来说,实际是创建了一个新的对象引用)。

def return_number():
    num = 10
    return num


result_num = return_number()
print(f"返回的数字: {result_num}")

这里 return_number 函数返回的 num 是一个新的对象引用,指向值为 10 的整数对象。即使在函数内部 num 是局部变量,函数结束后其生命周期结束,但返回的对象引用依然有效,并且在外部创建了一个新的引用 result_num 指向该对象。

可变对象返回值

当函数返回可变对象时,返回的是对象的引用。

def return_list():
    lst = [1, 2, 3]
    return lst


result_list = return_list()
print(f"返回的列表: {result_list}")

在这个例子中,return_list 函数返回的 lst 是列表对象的引用。函数外部的 result_list 获得了这个引用,指向了函数内部创建的列表对象。这意味着可以通过 result_list 对返回的列表进行修改。

利用返回值特性进行数据操作

可以利用函数返回值的这些特性进行数据操作。例如,对于不可变对象的返回值,可以安全地进行进一步计算,而不用担心影响原始对象。对于可变对象的返回值,可以在不传入原始对象的情况下对返回的对象进行修改。

def square_number(num):
    return num * num


def add_to_list_return(lst):
    new_lst = lst.copy()
    new_lst.append(4)
    return new_lst


original_num = 5
squared_num = square_number(original_num)
print(f"原始数字: {original_num}, 平方后的数字: {squared_num}")

original_list = [1, 2, 3]
new_list = add_to_list_return(original_list)
print(f"原始列表: {original_list}, 新列表: {new_list}")

在这个例子中,square_number 函数返回不可变对象的副本进行计算,add_to_list_return 函数返回可变对象的副本并进行修改,既保证了原始数据的安全性,又实现了数据的操作。

按值调用与按引用调用在类方法中的应用

类属性和实例属性的调用特性

在Python类中,类属性和实例属性在函数参数传递和修改时也体现出按值调用和按引用调用的特性。

class MyClass:
    class_attr = [1, 2, 3]

    def __init__(self):
        self.instance_attr = [4, 5, 6]

    def modify_class_attr(self):
        self.class_attr.append(4)

    def modify_instance_attr(self):
        self.instance_attr.append(7)


obj1 = MyClass()
obj2 = MyClass()

obj1.modify_class_attr()
print(f"obj1类属性: {obj1.class_attr}, obj2类属性: {obj2.class_attr}")

obj1.modify_instance_attr()
print(f"obj1实例属性: {obj1.instance_attr}, obj2实例属性: {obj2.instance_attr}")

在这个例子中,类属性 class_attr 是一个可变列表,当 obj1 调用 modify_class_attr 方法修改 class_attr 时,由于类属性是共享的,obj2class_attr 也会受到影响,这体现了类似按引用调用的特性。而实例属性 instance_attr 是每个实例独有的,obj1 调用 modify_instance_attr 方法修改 instance_attr 不会影响到 obj2instance_attr,这类似于按值调用(这里是每个实例有自己独立的对象引用)。

方法参数传递中的特性

当在类方法中传递参数时,同样遵循按对象引用传递的规则。

class AnotherClass:
    def method(self, num, lst):
        num = num + 1
        lst.append(4)
        return num, lst


obj = AnotherClass()
original_num = 5
original_list = [1, 2, 3]
result_num, result_list = obj.method(original_num, original_list)
print(f"原始数字: {original_num}, 函数返回数字: {result_num}")
print(f"原始列表: {original_list}, 函数返回列表: {result_list}")

AnotherClassmethod 方法中,num 是不可变对象,类似按值调用,函数内部的修改不会影响外部的 original_num;而 lst 是可变对象,类似按引用调用,函数内部的修改会影响到外部的 original_list

总结Python按值调用与按引用调用相关要点

通过以上详细的分析,我们可以看到Python在参数传递上采用按对象引用传递的方式,在处理不可变对象时类似按值调用,处理可变对象时类似按引用调用。这种方式既有按值调用保证数据安全性和可预测性的优点,又有按引用调用实现高效数据共享和修改的优势。

在实际编程中,开发者需要清楚地了解对象的可变与不可变特性,以及按对象引用传递的规则,合理地设计函数和类的行为,避免因意外修改共享数据而导致的错误。同时,通过传递副本、使用不可变数据结构等方法,可以更好地控制数据的修改,提高程序的稳定性和可维护性。

无论是在简单的函数调用,还是复杂的类方法调用、多线程编程等场景中,深入理解Python按值调用与按引用调用的本质,都有助于编写出高效、健壮的Python代码。