c++中拷贝构造函数的参数类型必须是引用

发布时间:2026/11/6 5:26:41
c++中拷贝构造函数的参数类型必须是引用

前言


拷贝构造函数(copy constructor)是用一个同类型对象初始化新对象的构造函数。标准规定的形式是T(const T&),参数必须是引用(可以是T&、const T&、volatile T&或const volatile T&)。如果写成按值T(T other),它不构成拷贝构造函数,而且会陷入「为了传参要拷贝,拷贝又要传参」的循环,编译器会直接拒绝。


常见的误解有三个。第一,以为「按值传参只是多一次拷贝,能编译就行」——实际上这类写法根本无法通过编译,因为按值传递实参本身就需要调用拷贝构造函数。第二,以为参数写成T&就万事大吉——非 const 引用无法绑定 const 对象和临时对象,导致const T a; T b = a;编译失败。第三,把拷贝构造与拷贝赋值混为一谈,参数类型和返回类型都写错。


本文先解释为什么引用是必要条件,再说明为什么通常要加const,然后给出一个管理动态数组的类,演示深拷贝、拷贝赋值与 Rule of Three/Five/Zero,最后列出签名与实现中的常见错误。


一、按值传参为什么行不通


假设允许这样写:


// 错误示例:无法通过编译
class Bad {
public:
Bad(Bad other); // 按值传参
};

当调用Bad b = a;时,编译器要初始化形参other,方式是「用实参a拷贝构造other」。这又要调用Bad的拷贝构造函数,而它的形参同样按值,又需要一次拷贝……如此递归,永远没有尽头。


标准从语言规则上直接禁止了这种声明:如果一个构造函数的第一参数是(可带 cv 限定的)自身类型、按值传递,且其余参数都有默认值,那么这个声明本身就是 ill-formed。原因正是上面说的递归——按值传参需要先拷贝实参,而拷贝实参又需要调用拷贝构造函数。编译器在声明处就会报错,不会等到调用时才失败。


不同编译器报错措辞不同,常见的是「copy constructor must pass its first argument by reference」或「非法构造函数:参数不能按值传递自身类型」。看到这类错误,第一反应应当是检查参数是不是漏了&。


二、为什么通常写成 const 引用


引用解决了递归问题,但引用的类型还有讲究。




写法能否复制 const 对象能否绑定临时对象说明



T(T&)否否只能复制非 const 左值

T(const T&)是是标准写法

T(volatile T&)否否极少使用

T(T&&)不适用是这是移动构造函数,不是拷贝构造



非 const 的T&不能绑定 const 对象,也不能绑定临时对象。考虑下面的代码:


// 适用于 C++17
const Widget a;
Widget b = a; // 需要 const T& 或 T&?a 是 const,只能绑定 const T&
Widget c = makeWidget(); // 返回值是临时对象,非 const 左值引用绑不上

如果拷贝构造函数写成Widget(Widget&),这两行都编译不过。写成const Widget&后,const 对象、非 const 对象、临时对象都能绑定,拷贝构造才在常见场景下可用。


T(T&&)是移动构造函数,不是拷贝构造函数。它接收右值,通常用于「窃取」资源。只定义移动构造、不定义拷贝构造时,用左值初始化新对象会失败,因为拷贝构造被隐式删除。反过来,只定义拷贝构造、不定义移动构造时,右值也会走拷贝,只是少了一次优化机会,不会编译失败。


三、一个需要深拷贝的例子


编译器会自动生成拷贝构造函数,行为是逐成员拷贝。对int、double、std::string、std::vector这些成员,逐成员拷贝是正确的;对裸指针,逐成员拷贝只复制地址,两个对象随后会指向同一块内存,析构时重复释放。


下面这个类管理一块动态数组,必须自己实现深拷贝:


// 适用于 C++17
#include <algorithm>
#include <cstddef>
#include <memory>
#include <utility>

class IntBuffer {
public:
explicit IntBuffer(std::size_t n)
: size_(n), data_(std::make_unique<int[]>(n)) {}

// 拷贝构造:参数是 const 引用
IntBuffer(const IntBuffer& other)
: size_(other.size_),
data_(std::make_unique<int[]>(other.size_)) {
std::copy(other.data_.get(), other.data_.get() + size_, data_.get());
}

// 拷贝赋值:参数同样是 const 引用,返回 *this 的引用
IntBuffer& operator=(const IntBuffer& other) {
if (this != &other) {
IntBuffer tmp(other); // 先构造副本,异常安全
swap(tmp);
}
return *this;
}

IntBuffer(IntBuffer&&) noexcept = default;
IntBuffer& operator=(IntBuffer&&) noexcept = default;
~IntBuffer() = default;

void swap(IntBuffer& other) noexcept {
std::swap(size_, other.size_);
data_.swap(other.data_);
}

std::size_t size() const { return size_; }
int& at(std::size_t i) { return data_[i]; }
const int& at(std::size_t i) const { return data_[i]; }

private:
std::size_t size_ = 0;
std::unique_ptr<int[]> data_;
};

逐点说明。


拷贝构造的参数是const IntBuffer&。初始化列表里先复制size_,再按同样大小分配新数组,然后用std::copy复制元素。两个对象各自持有一块内存,互不影响,这就是深拷贝。


拷贝赋值的参数也是const IntBuffer&,返回IntBuffer&。它先用拷贝构造创建临时对象tmp,再与*this交换。这样写的两个好处是:如果拷贝过程中抛异常,*this保持原状(强异常安全);临时对象析构时释放的是原来的内存,不需要在赋值函数里手写delete[]。


自赋值检查if (this != &other)不能省。虽然「先构造副本再交换」的写法即使自赋值也能得出正确结果(副本是自身的拷贝,交换后内容不变),但多一次分配和拷贝,且语义上应当明确处理。对更复杂的类,自赋值不检查可能直接出错。


移动操作显式设为默认。用户声明了拷贝构造、拷贝赋值和析构函数后,编译器不会隐式生成移动构造和移动赋值,右值会退回拷贝。这里补上= default,让unique_ptr的移动语义生效。


析构函数设为默认即可,unique_ptr<int[]>会自动调用delete[]。不要手写delete[] data_;,因为成员本身也会析构,会重复释放。


这个类遵循「Rule of Five」:定义了拷贝构造、拷贝赋值、移动构造、移动赋值、析构中的一部分,就把五个都明确处理。更好的做法是「Rule of Zero」:用std::vector<int>代替unique_ptr<int[]>加手动长度,五个特殊成员函数一个都不用写,编译器生成的版本就是正确的。


四、拷贝构造与拷贝赋值的区别




维度拷贝构造拷贝赋值



何时调用用已有对象初始化新对象两个已存在对象之间赋值

签名T(const T&)T& operator=(const T&)

返回值无(构造函数)*this的引用

对象状态新对象尚未构造左对象已有资源,需先释放或交换

是否可删除T(const T&) = delete;T& operator=(const T&) = delete;



// 适用于 C++17
IntBuffer a(10);
IntBuffer b = a; // 拷贝构造:b 还未构造
IntBuffer c(5);
c = a; // 拷贝赋值:c 已存在,先处理旧资源

IntBuffer b = a;虽然用了=,但它是初始化,不是赋值,调用的是拷贝构造。这一点经常被误认为「赋值」。


五、什么时候不需要自己写


如果类的成员都是 RAII 类型(容器、智能指针、字符串),编译器生成的拷贝构造会逐成员调用它们的拷贝构造,行为正确。这时应当遵循 Rule of Zero:不写析构、拷贝、移动中的任何一个,让编译器生成。


需要自己写的典型情况是:类直接持有裸指针、文件描述符、互斥量等资源。此时要么按 Rule of Five 全部处理,要么把资源封装进一个 RAII 成员,回到 Rule of Zero。后一种做法通常更好,因为手写五个函数容易漏掉某一个。


如果类本身不允许拷贝(例如表示独占所有权的句柄),用= delete明确禁用:


// 适用于 C++17
class UniqueHandle {
public:
UniqueHandle(const UniqueHandle&) = delete;
UniqueHandle& operator=(const UniqueHandle&) = delete;
};

删除拷贝构造后,任何复制尝试都会在编译期报错,而不是在运行期出现双重释放。


常见坑点


1. 参数按值传递❌Widget(Widget other);—— 不构成拷贝构造,且无法通过编译。 ✅Widget(const Widget& other);。


2. 参数忘记加引用符号❌Widget(const Widget other);—— 仍按值传递,同样错误。 ✅Widget(const Widget& other);。


3. 只写非 const 引用❌Widget(Widget& other);—— 无法复制 const 对象和临时对象。 ✅Widget(const Widget& other);。


4. 把移动构造当拷贝构造❌ 只写Widget(Widget&& other);,然后Widget b = a;(a 是左值)编译失败。 ✅ 拷贝构造接收const Widget&,移动构造接收Widget&&,两者可以并存。


5. 浅拷贝裸指针❌ 拷贝构造里只写data_ = other.data_;,两个对象共享内存,析构时重复释放。 ✅ 分配新内存并复制元素,或改用std::vector/std::unique_ptr等 RAII 成员。


6. 拷贝赋值不处理自赋值❌delete[] data_; data_ = new int[other.size_];在a = a时先释放了自己的数据。 ✅ 先构造副本再交换,或先判断this != &other。


7. 拷贝赋值返回 void❌void operator=(const Widget&);—— 不支持链式赋值,也不符合惯例。 ✅ 返回Widget&,函数末尾return *this;。


8. 写了拷贝构造却忘了拷贝赋值❌ 只定义拷贝构造,赋值时走编译器生成的逐成员拷贝,裸指针仍然浅拷贝。 ✅ 按 Rule of Three 同时处理拷贝构造、拷贝赋值、析构;或改用 RAII 成员实现 Rule of Zero。


总结




问题答案



参数为什么必须是引用按值传参需要先拷贝实参,会递归调用拷贝构造

为什么加 const既能复制 const 对象,也能绑定临时对象

能否是T&&那是移动构造,不是拷贝构造

能否有额外参数可以,但额外参数必须有默认值,且第一个参数仍须是自身类型的引用

什么时候自己写类直接管理裸资源时;否则优先 Rule of Zero

不允许拷贝怎么办= delete明确禁用



拷贝构造的参数类型不是风格问题,而是语言规则:引用避免了「拷贝实参」的无限递归,const让拷贝在 const 对象和临时对象上都能工作。记住T(const T&)这一个形式,再根据资源管理需求决定是否手写、是否禁用,就能避开这一族问题。