Tutorial: When to Write Which Special Member(编写C++特殊成员的指导原则)

本文介绍了C++特殊成员函数的使用规则,指出原特殊成员组合图虽覆盖所有组合但部分不合理。阐述了rule of zero、rule of five (six)等规则,还提及资源管理类、不可移动类等情形下特殊成员函数的编写,最后给出总结文章链接。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


C++中的 特殊成员,是指编译器可能会自动生成的成员函数或操作符,即 默认构造函数、析构函数、拷贝构造函数、移动构造函数,拷贝赋值操作符,移动赋值操作符这六种
在这里插入图片描述

当给人解释这些特殊成员的使用规则,以及什么时候需要自己手动编写其中的某个时,人们总会提起下面的这幅图。但是,我不认为这个图是特别有用的。

它覆盖了所有的组合,有些组合其实是不太合理的。在这篇文章中,我们主要介绍你实际应该知道的关于特殊成员的知识以及什么时候你该编写哪个组合。

特殊成员组合图

这幅图是由 Howard Hinnant 提供的:
在这里插入图片描述需要解释的几点:

  • 用户声明(“user-declared”)的 特殊成员是用户采用以下任一方式提供的:它可能是个具有完整定义的特殊成员 ,可能是和默认提供的成员相同的成员, 可能是添加了deleted标志的特殊成员。 例如编写 foo(const foo&) = default 将会阻止编译器提供默认构造函数

  • 编译器默认声明的特殊成员和用户采用= default定义的特殊成员行为相同。比如:一个默认拷贝构造函数拷贝构造所有的类成员

  • 编译器声明的 “deleted” 类的特殊成员和用户采用= delete定义的特殊成员行为相同。例如,如果重载决议决定采用由= delete定义的特殊成员函数,将会编译失败并报错:你正在使用一个声明为deleted的函数

  • 如果编译器没有声明某个特殊成员,该成员将不会参与重载决议。这和声明为deleted的成员不同,该成员会参与重载决议。例如,如果你定义了一个拷贝构造函数,编译器将不会生成移动构造函数。这样,如果你写 obj(std::move(other))这样的语句,将不会调到拷贝构造函数。另一方面,如果你显式的将移动构造函数声明为= deleted, 那么 对于语句obj(std::move(other)),编译器将会选择调用该移动构造函数,但是会编译不通过报错,因为它是deleted的

  • 标红的表格是不建议(deprecated)使用的方式,因为这种默认的行为是非常危险的。比如:用户提供了析构函数,采用编译器默认生成的拷贝构造函数和拷贝赋值操作符;用户提供了拷贝构造函数,采用编译器默认生成的拷贝赋值操作符;用户提供了拷贝赋值操作符,采用编译器默认生成的拷贝构造函数等,这些都是不建议的,非常危险的。

是的,那个表格非常复杂,它是在一个关于移动语义的报告上给出的,目的是展示一般的规则。

你不必真的完全理解里面的那些组合,你只需要知道下面具体某个场景该使用那个组合。

绝大多数情形:rule of zero

class normal
{
public:
    // rule of zero
};

rule of zero 又叫做 “The Rule of The Big Four (and a half)” ,它的意思是说,觉大多数类并不需要一个析构函数,那么你最好不要提供拷贝构造函数、移动构造函数、拷贝赋值操作符、移动赋值操作符。 编译器默认生成的成员就可以做正确的事。这条规则的意思是说你应将资源管理的工作交给标准库的来做,不要使用裸指针来管理或使用资源,这样也就不需要拷贝构造函数、移动构造函数、拷贝赋值操作符、移动赋值操作符等了。

只要情况允许,你应该尽量遵守rule of zero。

如果你没有任何构造函数,编译器将会为你的类生成一个默认构造函数。如果有,那么编译器就不会生成了。如果你要给类成员赋一个合理的默认值,那么你应该添加一个默认构造函数。

通常,不建议引进一个人为的“null”状态,如果需要,用std::optional<T>

容器类: Rule of Five (Six)

class container
{
public:
    container() noexcept;
    ~container() noexcept;

    container(const container& other);
    container(container&& other) noexcept;

    container& operator=(const container& other);
    container& operator=(container&& other) noexcept;
};

如果你需要释放某些动态申请的内存,那么你需要提供析构函数,但是编译器自动生成的拷贝构造和赋值操作符将会导致析函数不能正常工作,这种情况下,你也需要提供自己的拷贝构造和赋值操作符。

这就是人们通常说的rule of five:任何时候你提供了自定义的析构函数,你也要提供与之语义相匹配的拷贝构造函数和赋值操作符。出于性能考虑,你也应该提供移动构造函数和移动赋值操作符。

移动函数将会将初始对象的资源“偷”给新对象,让初始对象处于空的状态。尽量让移动函数具有良好的性能且不抛出异常。

现在你有一个构造函数,那么将不会有隐式的默认构造函数。大多数情况下,你应该实现一个默认的构造函数,其可以将该类置于一个空的状态,就像被move后的类一样。

加上你提供的默认构造函数,这就叫rule of six 了。

资源管理类:Move-only

class resource_handle
{
public:
    resource_handle() noexcept;
    ~resource_handle() noexcept;

    resource_handle(resource_handle&& other) noexcept;
    resource_handle& operator=(resource_handle&& other) noexcept;

    // resource_handle(const resource_handle&) = delete;
    // resource_handle& operator=(const resource_handle&) = delete;
};

有时候你可能需要自定义的析构函数,但是却不能实现拷贝构造函数。例如,你在写某个类,这个类是对文件句柄或是其它类似系统资源的封装。

对于这些类,尽量让它们只包含移动构造函数,也就是说让它们是move-only的。换句话说,对于这样的类,编写析构函数,移动构造函数和移动赋值操作符。

对照下上面Howard的图标,你会看到在用户提供移动构造函数和移动赋值操作符的情况下,拷贝构造函数和拷贝赋值操作符是deleted的。这是正确的,因为这样的类应该是move-only的。如果你想明确的表示你的意图,你可以手动的在拷贝构造函数和拷贝赋值操作符后加上=delete。

同样,你应该添加一个默认的构造函数,其可以将该类置于空的状态,等同于该类被move后状态。

Immoveable Classes

class immoveable
{
public:
    immoveable(const immoveable&) = delete; 
    immoveable& operator=(const immoveable&) = delete;

    // immoveable(immoveable&&) = delete;
    // immoveable& operator=(immoveable&&) = delete;
};

有时你希望某个类不能被拷贝和移动。一旦某个对象创建了,它将一直呆在它创建时的内存上。这在你想安全的创建指向该对象的指针时是有用的。

在这种情况下,你应该将你的拷贝构造函数设置为deleted的。这样,编译器将不会为你声明移动构造函数,这意味这所有的拷贝或者移动都会使用拷贝构造函数,但它是deleted的,所以你可以达到上面的目的。

你也应该将赋值操作符设置为deleted的。尽管它不实际移动对象,但他和对象的构造是密切相关的。看下面的部分:

Avoid:Rule of Three

class avoid
{
public:
    ~avoid();

    avoid(const avoid& other);
    avoid& operator=(const avoid& other);
};

如果你只实现了拷贝构造函数或者拷贝赋值操作符, move一个类对象将会触发拷贝构造函数的调用。很多代码假定move操作的代价比copy的要小,你也应该遵守这个假定。

如果你使用C++11, 出于性能提升的考虑,你应该实现移动构造函数和移动赋值操作符。

Don’t: Copy-Only Types

class dont
{
public:
    ~dont();

    dont(const dont& other);
    dont& operator=(const dont& other);

    dont(dont&&) = delete;
    dont& operator=(dont&&) = delete;
};

如果你实现了拷贝构造函数或者拷贝赋值操作符,同时将移动构造函数和移动赋值操作符设置为deleted的,它们仍将会参与名字重在决议。

这意味着:

dont a(other);            // okay
dont b(std::move(other)); // error: calling deleted function

这让人诧异,所以不要这样做。

Don’t: Deleted Default Constructor

class dont
{
public:
    dont() = delete;
};

没有任何理由将默认构造函数设置为=delete的,如果你不想要某个构造函数,写一个另外的构造函数就可以了。

唯一的例外是你想创建一个不能被实例化的类,但是这样的类是没有任何作用的。

所以还是别将构造函数设置为=delete的。

Don’t: Partial Implementation

class dont
{
public:
    dont(const dont&);
    dont& operator=(const dont&) = delete;
};

下面的内容也使用于移动构造函数或移动赋值操作符。

拷贝构造函数和拷贝赋值操作符是不可分割的一对。你要么都拥有,要么都不要。

从概念上来说,拷贝赋值只是一种快速的“destroy + copy construct”过程。所以如果你有拷贝构造函数,你将同时也拥有了拷贝赋值,因为它可以由一个析构调用和一个默认的拷贝构造组成。

一般的代码通常都要求类可以被拷贝。如果不是非常仔细的分析,你可能不太容易分清拷贝构造和拷贝赋值的区别。

尽管有一些学究式的争论:我们可以实现一个只可以拷贝构造但是不可以拷贝赋值的类型,或者可以拷贝赋值但是不能拷贝构造的类型,但是请你从实际出发,尽量避免这样做。

Consider: Swap

class consider
{
public:
    friend void swap(consider& lhs, consider& rhs) noexcept;
};

一些算法,特别是那些pre-move类型的算法,使用swap()来移动对象。如果你的对象没有提供一个可以通过ADL找到的swap(), 那么它将会使用std::swap();

std::swap() 内部由三次move操作组成:

template <typename T>
void swap(T& lhs, T& rhs)
{
    T tmp(std::move(lhs));
    lhs = std::move(rhs);
    rhs = std::move(tmp);
}

如果你可以实现一个更高效的swap(), 那么就做吧。当然,这只适用于一些实现了析构函数、拷贝构造函数或者移动构造函数的类。

你自己实现的swap()应该是noexcept类型的。

总结

基于上面的内容,我创建了一个新的关于特殊成员的总结文章 https://foonathan.net/special-member

下次你需要解释这些原则时,可以考虑使用我写的总结文章或者本文,而不是本文开头引用的那个一般的图。

原文链接

https://foonathan.net/blog/2019/02/26/special-member-functions.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值