C++函数参数传递:传值、引用与指针的性能对比与最佳实践
2026/9/4 6:57:34 网站建设 项目流程

这次我们来看一个 C++ 编程中的核心概念:向函数传递对象。对于 C++ 初学者和面试者来说,这不仅是语法问题,更是理解程序性能、内存管理和面向对象设计的关键。很多人写代码时,对传值、传引用、传指针的区别一知半解,导致程序效率低下或出现难以排查的 Bug。本文将直接切入主题,不讲空泛理论,而是聚焦于“怎么用”和“为什么这么用”。

我们将重点拆解三种传递方式:传值、传引用、传指针。每种方式都会从底层原理、内存开销、适用场景和实际代码效果进行对比。你会看到,一个简单的函数调用,背后可能隐藏着对象的拷贝构造、析构,甚至是不必要的性能损耗。对于需要处理大型对象(如图像、矩阵、复杂数据结构)或高频调用的场景,选择正确的传递方式至关重要。

本文适合正在学习 C++、准备技术面试,或在实际项目中希望优化代码性能的开发者。我们将通过具体的代码示例,演示不同传递方式下的行为差异,并给出在不同场景下的最佳实践建议。读完本文,你将能清晰地判断何时该用const引用,何时该用指针,以及如何避免常见的“对象切片”等问题。

1. 核心能力速览:对象传递方式对比

在深入细节之前,我们先通过一个表格快速了解向 C++ 函数传递对象的几种核心方式及其关键特性。这能帮助你快速建立整体认知,后续我们再逐一深入。

传递方式语法示例内存与性能影响函数内能否修改原对象主要适用场景
传值 (Pass by Value)void func(MyClass obj)触发拷贝构造函数,产生对象副本。开销大,尤其对于大型对象。不能。修改的是副本。需要函数内独立操作副本,且对象很小或拷贝成本可接受。
传引用 (Pass by Reference)void func(MyClass &obj)不拷贝对象,传递的是原对象的别名(内存地址)。零拷贝开销。能。直接修改原对象。需要修改原对象,或避免大型对象拷贝以提升性能。
传常引用 (Pass by const Reference)void func(const MyClass &obj)不拷贝对象,零拷贝开销。不能。编译器保证对象只读。最常用。不需要修改原对象,只想读取其值,同时避免拷贝开销。
传指针 (Pass by Pointer)void func(MyClass *obj)传递对象地址(通常4或8字节),不拷贝对象本身。开销极小。能。通过指针解引用(->,*)修改。需要修改原对象,且对象可能为空(nullptr);或明确需要指针语义(如链表节点)。
传常指针 (Pass by const Pointer)void func(const MyClass *obj)同传指针,开销小。不能。通过指针只能读取对象。需要指针语义(如可能为空),但函数内不允许修改对象。

核心要点速记

  • 性能优先选引用:尤其是const引用,它是平衡性能与安全的黄金标准。
  • 修改原对象用引用或指针:根据是否需要nullptr状态来决定。
  • 慎用传值:除非对象很小(如内置类型、简单的struct)或确需副本。
  • 理解拷贝成本:对于含有动态内存(如std::vector,std::string)或复杂资源的类,拷贝可能非常昂贵。

2. 适用场景与使用边界

理解不同传递方式的适用场景,能让你在编码时做出更明智的选择,避免性能陷阱和设计缺陷。

1. 传值 (Pass by Value) 的使用边界:

  • 适用场景:对象本身非常小,例如基本数据类型(int,double)、小型结构体(仅包含几个基本类型成员),且拷贝构造函数的成本极低。有时,函数逻辑确实需要一份完全独立的副本进行操作,防止原数据被意外修改。
  • 不适用场景绝对避免对大型对象(如包含数万元素的std::vector)、管理资源的对象(如文件句柄、网络连接)或具有非平凡拷贝构造函数的类使用传值。这会导致不必要的深度拷贝,严重消耗 CPU 和内存。
  • 典型错误:在循环或递归函数中高频次传递大型对象,性能会呈指数级下降。

2. 传引用与传常引用 (Pass by (const) Reference) 的黄金法则:

  • const MyClass&(常引用) 是默认选择:这是 C++ 社区广泛认可的最佳实践。只要函数不需要修改传入的对象,就应该使用const引用。它完美避免了拷贝,同时通过const限定符明确了函数的只读意图,提高了代码的可读性和安全性。
  • MyClass&(非常引用) 用于明确修改:当函数的设计目的就是修改传入对象的状态时,使用非常引用。这使调用者一目了然地知道,调用这个函数后,传入的对象可能会变。
  • 使用边界:引用必须绑定到一个已存在的有效对象,不能为“空”。这既是安全保证(减少空指针异常),也是限制(当“无对象”也是一种有效状态时,需用指针)。

3. 传指针 (Pass by Pointer) 的特定场景:

  • 需要表达“可选”或“可能不存在”:当nullptr是一个有意义的输入时(例如,查找函数未找到目标返回空,或某些配置参数可选),必须使用指针。
  • 需要重新绑定:指针变量本身可以被重新赋值,指向另一个对象。而引用一旦绑定,在其生命周期内无法更改。
  • 操作动态分配的对象数组:虽然现代 C++ 更推荐使用std::vector等容器,但在某些底层或与 C 接口交互的场景,仍需操作原生指针。
  • 使用边界:指针增加了代码的复杂性,调用者需要检查指针是否有效,函数内部也需要处理空指针的情况。滥用指针会降低代码安全性。

4. 涉及继承与多态时的关键边界——“对象切片”(Object Slicing):这是一个使用传值时极易踩中的大坑。当派生类对象通过传值方式传递给期望基类对象的函数时,会发生“切片”:派生类特有的成员数据和方法会被“切掉”,只保留基类部分。这完全破坏了面向对象的多态性。解决“切片”问题的唯一正确方式就是使用引用或指针

3. 环境准备与前置条件

要实践和验证本文的所有代码示例,你只需要一个可运行的 C++ 开发环境。以下是通用的准备清单:

  1. 编译器:支持 C++11 或更高标准的编译器。推荐:
    • GCC(MinGW-w64 for Windows): 版本 7.0 或更高。
    • Clang: 版本 5.0 或更高。
    • Microsoft Visual C++ (MSVC): Visual Studio 2017 或更高版本。
  2. 开发工具(任选其一)
    • 集成开发环境 (IDE)Visual Studio(Windows),Visual Studio Code(配合 C++ 扩展),CLion,Qt Creator。IDE 提供代码高亮、调试、项目管理,适合初学者和大型项目。
    • 文本编辑器 + 命令行:如 VSCode/Sublime/Notepad++ 配合终端,使用g++/clang++命令编译。更轻量,适合快速测试。
  3. 基础 C++ 知识:了解类与对象的基本概念、构造函数、成员函数。
  4. 一个简单的测试项目:创建一个空目录,用于存放我们的示例代码文件(如main.cpp)。

验证环境是否就绪: 打开终端(或命令提示符/PowerShell),输入以下命令检查编译器版本:

# 检查 GCC 版本 g++ --version # 检查 Clang 版本 clang++ --version # 对于 MSVC,通常需要在 Visual Studio 的开发人员命令提示符中运行 cl /?

如果看到版本信息,说明编译器已安装。接下来,我们就可以进入具体的代码实践环节。

4. 从代码看本质:三种传递方式详解

理论说再多,不如一行代码。我们通过一个具体的Student类,来直观感受不同传递方式的区别。

首先,定义一个包含拷贝构造函数的类,以便观察对象何时被拷贝:

#include <iostream> #include <string> class Student { public: std::string name; int age; // 构造函数 Student(const std::string& n, int a) : name(n), age(a) { std::cout << "构造函数被调用: " << name << std::endl; } // 拷贝构造函数 Student(const Student& other) : name(other.name), age(other.age) { std::cout << "拷贝构造函数被调用,从 " << other.name << " 创建副本" << std::endl; } // 析构函数 ~Student() { std::cout << "析构函数被调用: " << name << std::endl; } void introduce() const { std::cout << "我叫 " << name << ", 今年 " << age << " 岁。" << std::endl; } };

4.1 场景一:传值 (Pass by Value)

// 传值函数:接收一个Student对象的副本 void modifyStudentByValue(Student stu) { stu.name = "李四(副本)"; stu.age = 25; std::cout << "函数内修改后: "; stu.introduce(); } int main() { std::cout << "=== 测试传值 ===" << std::endl; Student alice("张三", 20); std::cout << "调用函数前: "; alice.introduce(); modifyStudentByValue(alice); // 这里会发生拷贝! std::cout << "调用函数后,原对象: "; alice.introduce(); // 原对象未被修改 return 0; }

运行结果分析:

=== 测试传值 === 构造函数被调用: 张三 调用函数前: 我叫 张三, 今年 20 岁。 拷贝构造函数被调用,从 张三 创建副本 函数内修改后: 我叫 李四(副本), 今年 25 岁。 析构函数被调用: 李四(副本) // 函数结束,副本被销毁 调用函数后,原对象: 我叫 张三, 今年 20 岁。 析构函数被调用: 张三 // main函数结束,原对象被销毁

关键观察点:

  1. 拷贝发生:在modifyStudentByValue(alice)调用时,拷贝构造函数被触发,生成了alice的一个完整副本。
  2. 修改无效:函数内修改的是这个副本 (stu),原对象alice毫发无损。
  3. 额外开销:一次构造(原对象)+ 一次拷贝(副本)+ 两次析构。如果Student类内部有std::vector<int> scores这样的成员,拷贝代价会更大。

4.2 场景二:传引用 (Pass by Reference)

// 传引用函数:接收原对象的别名 void modifyStudentByReference(Student& stu) { stu.name = "李四"; stu.age = 25; std::cout << "函数内修改后: "; stu.introduce(); } int main() { std::cout << "\n=== 测试传引用 ===" << std::endl; Student bob("张三", 20); std::cout << "调用函数前: "; bob.introduce(); modifyStudentByReference(bob); // 直接传递原对象,无拷贝! std::cout << "调用函数后,原对象: "; bob.introduce(); // 原对象已被修改 return 0; }

运行结果分析:

=== 测试传引用 === 构造函数被调用: 张三 调用函数前: 我叫 张三, 今年 20 岁。 函数内修改后: 我叫 李四, 今年 25 岁。 调用函数后,原对象: 我叫 李四, 今年 25 岁。 析构函数被调用: 李四

关键观察点:

  1. 零拷贝:调用modifyStudentByReference(bob)时,没有拷贝构造函数调用stu只是bob的一个别名,操作的是同一块内存。
  2. 修改生效:函数内对stu的修改,直接作用在bob上。
  3. 性能最优:没有任何额外的对象构造/拷贝开销。这是需要修改原对象时的标准做法。

4.3 场景三:传常引用 (Pass by const Reference)

// 传常引用函数:只读访问,禁止修改 void printStudent(const Student& stu) { // stu.name = "尝试修改"; // 错误!编译不通过,stu是const的 std::cout << "只读访问学生信息: "; stu.introduce(); // introduce() 必须是 const 成员函数才能被调用 } int main() { std::cout << "\n=== 测试传常引用 ===" << std::endl; Student charlie("王五", 22); printStudent(charlie); // 安全、高效的只读传递 return 0; }

关键观察点:

  1. 安全与高效:同样零拷贝,同时编译器保证函数内部不能修改对象。这是“只读不写”场景的首选
  2. 接口清晰:函数签名void printStudent(const Student&)明确告知调用者:“我不会动你的数据”。
  3. 对成员函数的要求:在常引用对象上,只能调用其const成员函数(如introduce() const)。这是一个良好的设计约束。

4.4 场景四:传指针 (Pass by Pointer)

// 传指针函数:可能接收空指针 void modifyStudentByPointer(Student* pStu) { if (pStu == nullptr) { std::cout << "传入的是空指针,无法修改。" << std::endl; return; } pStu->name = "赵六"; pStu->age = 30; std::cout << "函数内修改后: "; pStu->introduce(); } int main() { std::cout << "\n=== 测试传指针 ===" << std::endl; Student david("张三", 20); Student* ptr = &david; // 获取对象地址 Student* nullPtr = nullptr; std::cout << "调用函数前: "; david.introduce(); modifyStudentByPointer(ptr); // 传递有效指针 std::cout << "调用函数后: "; david.introduce(); modifyStudentByPointer(nullPtr); // 传递空指针 return 0; }

关键观察点:

  1. 显式地址操作:需要取地址符&获取指针,函数内用->*操作对象。
  2. 空值处理:指针可以为nullptr,函数内部必须进行有效性检查,否则会导致未定义行为(崩溃)。
  3. 与引用的选择:除非你需要“无对象”这个状态,或者需要操作动态数组、兼容 C 接口,否则在 C++ 中,引用通常比指针更安全、更清晰。

5. 功能测试与效果验证:性能与“切片”陷阱

理解了基本语法,我们还需要验证两个关键问题:性能差异面向对象中的经典陷阱

5.1 性能对比测试

让我们创建一个“重型”对象,模拟实际开发中的复杂数据,并测试高频调用下不同传递方式的性能差异。

#include <iostream> #include <vector> #include <chrono> class HeavyObject { public: std::vector<int> bigData; // 模拟大量数据 HeavyObject() { bigData.resize(1000000, 1); // 分配100万个整数 } // 注意:这里编译器会生成默认的拷贝构造函数,它会逐个拷贝vector中的元素,代价巨大! }; // 1. 传值 - 性能灾难 void processByValue(HeavyObject obj) { // 做一些无关紧要的操作 int sum = obj.bigData[0]; } // 2. 传常引用 - 高效 void processByConstRef(const HeavyObject& obj) { int sum = obj.bigData[0]; } int main() { HeavyObject heavy; auto start = std::chrono::high_resolution_clock::now(); for (int i = 0; i < 100; ++i) { processByValue(heavy); // 每次调用都拷贝100万个int! } auto end = std::chrono::high_resolution_clock::now(); auto duration_value = std::chrono::duration_cast<std::chrono::milliseconds>(end - start); std::cout << "传值调用100次耗时: " << duration_value.count() << " 毫秒" << std::endl; start = std::chrono::high_resolution_clock::now(); for (int i = 0; i < 100; ++i) { processByConstRef(heavy); // 零拷贝 } end = std::chrono::high_resolution_clock::now(); auto duration_ref = std::chrono::duration_cast<std::chrono::milliseconds>(end - start); std::cout << "传常引用调用100次耗时: " << duration_ref.count() << " 毫秒" << std::endl; std::cout << "性能差距倍数: " << (double)duration_value.count() / duration_ref.count() << std::endl; return 0; }

预期结果与验证processByValue的耗时将远高于processByConstRef,差距可能达到数百甚至上千倍。这直观地证明了对于非平凡对象,传值的性能开销是致命的。

5.2 “对象切片”(Object Slicing) 陷阱验证

这是面向对象编程中一个隐蔽但严重的错误。

#include <iostream> #include <string> class Animal { public: std::string type = "Animal"; virtual void makeSound() const { // 虚函数,实现多态 std::cout << type << " makes a sound." << std::endl; } }; class Dog : public Animal { public: Dog() { type = "Dog"; } virtual void makeSound() const override { std::cout << type << " barks: Woof! Woof!" << std::endl; } void fetch() { // Dog特有的方法 std::cout << "Dog fetches the ball." << std::endl; } }; // 错误示例:传值导致切片 void playWithAnimalByValue(Animal a) { a.makeSound(); // 这里调用的是Animal的makeSound,不是Dog的! // a.fetch(); // 错误!Animal类没有fetch方法,编译不通过 } // 正确示例:传引用保持多态 void playWithAnimalByReference(Animal& a) { a.makeSound(); // 正确!根据a实际指向的对象类型调用对应函数 } int main() { Dog myDog; std::cout << "=== 传值导致对象切片 ===" << std::endl; playWithAnimalByValue(myDog); // 发生切片!Dog特有的部分被切掉 std::cout << "\n=== 传引用保持多态性 ===" << std::endl; playWithAnimalByReference(myDog); // 正确!调用Dog::makeSound() return 0; }

运行结果分析:

=== 传值导致对象切片 === Animal makes a sound. // 糟糕!我们丢失了“Dog”的身份和“Bark”的行为 === 传引用保持多态性 === Dog barks: Woof! Woof! // 正确!多态性得以保留

结论验证:当派生类对象通过传值给基类参数时,会发生“切片”,派生类特有的数据成员和虚表指针(用于多态)都会被丢弃,对象被“降级”为基类对象。要避免切片,在涉及继承体系的函数参数中,必须使用引用或指针。

6. 接口设计与最佳实践建议

在实际项目开发中,如何选择传递方式?以下是一些经过验证的最佳实践:

  1. 默认使用const T&(常引用)

    • 准则:对于输入参数(函数只读取不修改),除非对象是内置类型或非常小,否则一律使用const引用。
    • 优点:零拷贝、安全、意图明确。
    • 示例void printData(const std::vector<int>& data);bool isValid(const Student& stu);
  2. 需要修改参数时,使用T&(非常引用)

    • 准则:明确告知调用者,此函数会修改传入的对象。
    • 示例void normalizeVector(std::vector<double>& vec);void loadConfig(Config& cfg);
  3. 输出参数或多返回值,考虑使用指针或引用

    • 在 C++11 之前,常用指针或引用来实现“多个返回值”。现在更推荐使用std::tuple或返回结构体。
    • 如果仍需使用,指针可以表达“可选”输出(nullptr表示不关心此结果),而引用则要求输出必须有效。
    // 传统方式:使用指针作为输出参数 bool tryParse(const std::string& str, int* outValue) { // ... 解析逻辑 if (成功) { if (outValue) *outValue = parsedValue; // 检查指针非空 return true; } return false; } // 现代方式:返回 std::optional std::optional<int> tryParse(const std::string& str) { ... }
  4. 小型、平凡类型可以直接传值

    • 准则:对于int,double,char,bool等内置类型,以及简单的POD(Plain Old Data) 结构体(仅包含此类成员),传值通常更高效或至少没有区别,代码也更简洁。
    • 示例void setValue(int x);Point addPoints(Point a, Point b);(假设Point只有x, y两个double
  5. 移动语义 (C++11+) 用于“接收”资源

    • 对于需要“夺取”参数所有权(即函数调用后,原对象不再有效)的场景,使用传值并配合std::move
    • 示例void takeOwnership(std::unique_ptr<Resource> ptr);void appendData(std::vector<int> data);(函数内部可能会移动data的内容)
  6. 在 API 设计中保持一致性

    • 在整个项目或库中,对相似功能的参数传递方式应保持一致,降低使用者的认知负担。

7. 常见问题与排查方法

在实际编码和面试中,关于对象传递的疑问和错误层出不穷。下表汇总了典型问题及其解决方法。

问题现象可能原因排查方式解决方案
程序运行缓慢,特别是循环调用函数时。对大型对象使用了传值,导致不必要的深度拷贝。1. 检查函数签名。
2. 在类的拷贝构造函数中打印日志,观察调用次数。
将参数改为const T&
函数内部修改了参数,但调用后原对象没变。使用了传值,函数操作的是副本。检查函数签名是否为func(T obj)如果需要修改原对象,改为func(T& obj)
编译错误:passing ‘const X’ as ‘this’ argument discards qualifiersconst对象或const引用上调用了非const成员函数。检查函数参数是否为const T&,以及调用的成员函数是否被声明为const将成员函数改为const成员函数,例如int getValue() const;
派生类对象传入基类参数函数后,行为异常(多态失效)。发生了“对象切片”。函数参数是基类类型传值。检查函数签名。如果参数是基类类型且非引用/指针,则会发生切片。将参数改为基类的引用或指针,如func(Base& b)func(Base* b)
函数需要处理“空”或“未设置”的参数。使用了引用,但引用不能绑定到空值。函数逻辑是否需要区分“有有效对象”和“无对象”两种情况。改用指针,并在函数内部检查nullptr。或使用std::optional<T&>(C++17 起需封装)。
函数想返回多个值。试图用多个引用参数作为输出,代码可读性差。审视函数设计。优先考虑返回一个结构体structstd::tuple

8. 总结与下一步

向函数传递对象,是 C++ 编码中每天都会发生的操作。选择哪种方式,直接反映了你对程序性能、内存安全和设计意图的理解深度。记住这个简单的决策链:

  1. 函数是否需要修改传入的对象?
    • -> 使用T&(非常引用)。
    • -> 进入第2步。
  2. 对象是否“很小”(如内置类型、小型POD)?
    • -> 可以考虑传值T,通常没问题。
    • ->默认使用const T&(常引用)
  3. 参数是否可以/需要为“空”?
    • -> 使用T*(指针),并处理nullptr
    • -> 继续使用引用。
  4. 是否涉及继承和多态?
    • ->必须使用基类的引用或指针 (Base&/Base*) 来避免切片。

要真正掌握这些知识,最好的方法是动手实验。你可以:

  • 修改本文的示例代码:尝试给Student类增加一个std::vector<int> scores成员,再观察拷贝构造的开销。
  • 模拟面试题:自己出题,例如“写一个函数交换两个大型矩阵,说明参数应如何传递及原因”。
  • 阅读优秀开源代码:如 C++ 标准库 (STL) 的实现,观察其中函数参数的设计,例如std::sort的迭代器参数、容器类push_back的参数等。

理解对象传递,是写出高效、健壮 C++ 代码的基石。建议将本文中的示例代码运行一遍,观察控制台输出,让这些概念从记忆变成直觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询