了解C++的特殊成员函数

 2024-02-23 00:04:39  阅读 0

C++明确指出:当通过父类指针删除子类对象,且父类有非析构函数时,结果是未定义的。 实际情况通常是对象的子类组件没有被销毁,导致内存泄漏。 如果发生这种情况,调试就不容易了。

class Basic { ... };
class Derived : public Basic {  ...  };
int main(){
    // ...
    Basic* ptr = new Derived;
    // ...
    delete ptr;  // 可能会内存泄露!
}

一般来说,内存泄漏不是那么明显。 例如,您设计一个()函数,它返回一个指向新生成的派生类对象的基类指针。 那么直接drop这个指针也可能会导致内存泄漏。 再比如,标准库根本就没有虚函数。 如果将其作为基类使用,也可能会出现上述情况。 (标准 STL 容器并不是设计为基类的,更不用说多态性了。)

class MyString : public std::string { ... };
int main(){
    MyString* pss = new MyString("Hello World!");
    std::string* ps;
    // ...
    ps = pss;
    // ...
    delete ps; // 可能会内存泄露!
}

这个问题的解决办法很简单,就是给basic一个虚析构函数。 之后,删除派生类对象就会如我们所料:先执行派生类析构函数,然后执行基类析构函数。 最终的结果是整个对象被破坏。

一般来说,如果一个类不包含虚函数,它可能不想成为某个类的父类。 那么就不需要将其析构函数设为虚函数。 因为它会增加性能损失(最简单的,需要增加指向vptr的void指针的大小)。 人们普遍认为,只有当一个类至少包含一个虚函数时,才应该为该类声明虚析构函数。

(如果您明确想要阻止从新类派生类,则应该添加 Final 说明符。)

有时,拥有一个带有纯虚拟析构函数的基类可能会更方便 - 在这种情况下,该类将成为抽象基类并且无法实例化。 当你声明一个纯虚析构函数时,如果你写:

class Base
{
public:
    Base() {}
    virtual ~Base() = 0;
};
class Derived : public Base { ... };

链接器将报告错误。 析构函数和构造函数与其他内部函数不同。 调用时,编译器需要生成调用链。 即隐式调用了Base的析构函数。 刚才的代码中缺少~Base()的函数体。 当然会出现错误。 这里有一个误解。 有人认为f()=0这样的纯虚函数语法是未定义体的语义。 事实上,这是不正确的。 这样的语法仅仅表明这个函数是一个纯虚函数,所以这个类就变成了一个抽象类,不能生成对象。 我们绝对可以为纯虚函数指定一个函数体。 普通的纯虚函数不需要函数体,因为我们一般不会调用抽象类的这个函数,而只调用派生类的对应函数。 所以。 我们现在有了纯虚拟析构函数的函数体。 上面的代码需要修改为:

class Base  
{  
    public:  
        Base(){}  
        virtual ~Base() = 0; //pure virtual  
};  
Base::~Base(){  }

(事实上​​,您可以提供纯虚函数的定义。该函数必须显式调用。)

有关详细信息,请参阅 Scott C++_ Item 07。

移动构造函数和移动赋值运算符

在理解移动语义(move)之前,我们需要先理解复制语义(copy)。 复制语义(copy)会将目标对象的状态设置为与源对象相同,而不修改源对象。 例如,将字符串变量s1复制到s2的结果是两个完全独立且状态相同的字符串。

然而,在很多现实生活场景中,我们并不是复制物体,而是移动物体。 例如,当我们付房租时,房东从我们的银行账户中取出钱,这就是移动物体。 另外,我们将SIM卡从手机中取出,并将SIM卡安装到新手机中,这也是移动的。 我们经常使用的电脑上的复制粘贴功能实际上是一种移动操作

复制和移动不仅仅是概念上的不同。 实际上,移动操作通常比复制操作快得多:移动操作只是将已创建的资源移动到新位置,而复制操作则创建一个新位置。 资源,然后复制旧资源的状态。 对于返回值是值类型的函数,移动操作的改进尤其明显:

std::string func()
{
    std::string s;
    // do something with s
    return s;
}
// ...
std::string mystr = func();

我们暂时不考虑编译器的返回值优化(RVO)。 对于上面的代码,当func()函数返回时,C++编译器会在调用其函数的堆栈内存上生成一个临时对象复制对象s。 然后销毁对象s,并以生成的临时对象作为参数复制构造mystr。 对象,mystr对象构造完成后,临时对象也被销毁。 可以看到整个过程包含了大量的复制和销毁操作。 如果使用移动操作,则可以减少此类不必要的复制和销毁操作。 移动字符串对象几乎是免费的。 只需要将源字符串对象的数据成员变量的值赋给目标字符串对象的数据成员变量即可。 复制字符串对象需要动态分配内存,然后从源字符串对象中复制每个字符。

当然,您的编译器可能足够聪明,可以为您优化这一点。 在C++中,有一种技术叫做“NRVO(命名值优化)”,即如果一个函数返回一个临时对象,该对象将被函数调用者直接使用,而无需创建新的对象。 )

C++11 为移动操作引入了两个新的特殊成员函数:移动构造函数(move)和移动赋值运算符(move)函数。

以前面讨论的复制赋值运算符的Array类为例,它的移动操作应该这样写:

Array(Array &&a) noexcept
{
    size = a.size;
    data = a.data;
    a.size = 0;
    a.data = nullptr;
    /**
     * 等价于:
     * size = std::move(a.size);
     * data = std::move(a.data);
    */
}
Array& operator=(Array &&a) noexcept
{
    if (this != &a)
    {
        delete[] data;
        size = a.size;
        data = a.data;
        a.size = 0;
        a.data = nullptr;
    }
    return *this;
}

您可以发现移动操作和复制操作之间的区别:

并且通过比较我们可以知道移动操作的复杂度为$O(1)$,效率更高。 这就是我们使用移动操作的原因,它可以用来代替昂贵的复制操作。

生成机制

默认情况下,我们有一个移动构造函数和一个移动赋值运算符。 但是如果我们在类中定义了移动构造函数,编译器不会自动为我们生成移动赋值运算符。 另一方面,如果我们在类中定义了移动赋值运算符,编译器不会自动为我们生成移动构造函数。 这与复制构造函数/赋值构造运算符对不同,其中声明一个不会影响另一个。

如果我们在类中定义了复制构造函数、复制赋值运算符或析构函数,编译器不会为我们生成移动构造函数和移动赋值运算符。 如果此时执行移动操作,则会执行复制操作。 但如果我们定义了移动构造函数,编译器不会自动为我们生成移动赋值运算符。 此时,对移动赋值运算符的调用不会转而执行复制赋值运算符,而是会产生编译错误。 。

成员函数方法_成员函数与类方法_成员函数和方法的区别

class MyClass
{
public:
    MyClass() {};
    /*  
        我们定义了移动构造函数,
        这会禁止编译器自动生成移动赋值运算符,
        并且对移动赋值运算符的调用会产生编译错误。
    */
    MyClass(MyClass&& rValue) noexcept { };
};
// ...
MyClass A{};
MyClass B{};
B = std::move(A);  
// 对移动赋值运算符的调用产生编译错误:attempting to reference a deleted function

因此,为了防止移动操作被抑制,使用=是一个好方法。 这样做会让你的意图更加清晰,并防止一些错误。 例如:

class StringTable {
public:
    StringTable() { };
// ...
private:
    std::map<int, std::string> values;
};

这是一个表示字符串表的类,即允许通过重塑字符串来检索字符串值。 假设这个类没有复制或移动操作,也没有编写析构函数,那么编译器在需要调用的时候自然会生成这些函数,而无需我们操心。

假设一段时间后,您决定记录对象的默认构造和销毁。 这也很简单:

class StringTable {
public:
    StringTable() 
    { makeLogEntry( "Creating StringTable object" ); }
    ~StringTable()
    { makeLogEntry( "Destorying StringTable object" ); }
// ...
private:
    std::map<int, std::string> values;
};

人们很容易忘记声明析构函数有一个很大的副作用:它阻止生成移动操作。 虽然这段代码可以正常编译运行,但是对 move 操作的请求反而会触发复制操作,这意味着 std::map 对象的复制可能会比 move 操作慢几个数量级,导致客观性能下降问题。 这就是用“=”显式定义移动操作的含义。

这是另一个例子。 std:: 的内存重新分配问题。 众所周知,std::使用动态内存。 当我们用()在末尾插入一个新元素时,该函数首先检查是否有空闲空间。 如果有,则直接在空闲空间上构造元素,否则扩展空间(重新配置、移动数据、释放原有空间)。 这涉及到 gcc STL 上的操作,大致如下:如果其移动构造函数为 ,则移动该元素,否则复制该元素。 如果由于类似于上面代码的原因而没有生成元素的移动操作,那么还有另一个显着的性能开销。 具体可以参考解释STL源代码的书籍。

函数重载

为了支持右值引用,C++11 修改了函数重载的规则。 例如,像 std::::() 这样的标准库函数现在有两个重载版本,一个使用 const T& 作为先前左值引用的参数,另一个使用新的 T&& 作为右值引用。

std::vector<Array> vm;
vm.push_back(Array(1024)); 
vm.push_back(Array(2048)); //push_back(const T&&)

因为参数是右值,所以上面代码中的两个 () 调用实际上调用了 (T&&)。 (T&&) 函数使用 Array 将资源从参数给定的 Array 对象移动到内部 Array 对象。 对于以前版本的C++,由于调用了复制构造函数,因此会发生额外的内存分配和复制。

前面提到,当参数为左值时,会调用 (const T&) 函数:

std::vector<Array> vm;
Array arr(1024); 
vm.push_back(arr); //push_back(const T&)

我们仍然可以使用强制转换左值到右值,或者我们可以使用 std::move() 来完成转换。

std::vector<Array> vm;
Array arr(1024); 
vm.push_back(std::move(arr)); //push_back(T&& )

看起来大多数情况下,我们需要的是 (T&&) 函数,但我们需要记住 (T&&) 会将源对象的状态设置为 null。 如果我们希望源对象的状态保持不变,我们应该使用复制构造语义。 一般来说,我们应该同时定义一个移动构造函数、一个移动赋值运算符函数、一个复制构造函数和一个复制赋值运算符函数。

详细的移动操作和

我们需要提到一个概念,即“强异常安全保障()”。 所谓强异常安全保证,是指当我们调用函数时,如果发生异常,应用程序的状态可以回滚到函数调用之前的状态。 在C++11中,一种自然的优化就是用对容器类型对象的元素的移动操作来代替复制操作,但这样做可能会违反强异常安全保证。

比如容器的()函数就有很强的异常保证,也就是说当()函数在执行一个操作的过程中(由于内存不足需要申请新的内存,将旧的元素放入new内存等),如果发生异常(内存空间不足无法申请等),()函数需要保证可以回滚到调用之前申请的状态。 否则,如果在资源移动过程中抛出异常,则原有正在处理的对象数据可能会因异常而丢失,程序无法完成回滚(即恢复程序状态)。

STL中的大多数容器类型都会调用容器元素的移动构造函数来移动“”中的资源,即当容器调整大小时。 为了保证容器类型的内存安全,大多数情况下,只标记不抛出异常的A移动构造函数,即标记为“”或“(true)”,否则其复制构造函数将被而是打电话。 因此,如果你想使用移动构造函数将旧元素放入新内存中,你需要告诉编译器:我们的移动构造函数不会抛出异常,你可以放心使用。 这是通过说明符完成的。

关于 std::swap() 的一些事情

在swap中,默认会使用对象的右值引用来移动。 如果没有右值引用,将调用默认的复制构造函数。 大概可以这样理解:

template <typename T> 
void swap (T& a, T& b)
{
    T c = std::move(a); 
    a = std::move(b); 
    b = std::move(c);
}

只要类型T支持复制(或移动),swap就可以完成交换。

但看看这个程序:

class A
{
public:
    A(int value) : p(new int(value)) { };
    ~A() { delete p; }
private:
    int *p;
};
int main()
{
    A a(1);
    A b(2);
    std::swap(a, b);
    return 0;
}

看似没有问题,但一旦执行,程序就会坠入深渊。 这里有一个非常隐蔽的问题。 回到std::swap()的代码,这里给临时变量c赋值。 在std::swap()函数结束时,c将被销毁,即调用它的析构函数并删除它的指针。 p。 但此时b中的指针所指向的地址与c中的指针所指向的地址相同,但已经指向了一块被删除的内存区域。 程序结束时,b也会被析构,所以指针又被击中了。

一种想法是使用模板专业化。 我们不能直接用std::swap()交换两个对象中的指针,因为p是私有成员变量; 我们可以将其声明为,但更合适的方法是:在 A 中声明一个名为 swap 的成员函数 要进行实际的交换,请专门化 std::swap()。 这与 STL 容器一致,因为所有 STL 容器还提供 swap() 成员函数和 std::swap() 的专门化来调用前者。 如下:

using std::swap;
class A
{
public:
    A(int value) : p(new int(value)){};
    ~A() { delete p; }
    void swap(A &other)
    { swap(p, other.p); }
private:
    int *p;
};
template <>
void std::swap(A &a, A &b)
{ a.swap(b); }
int main()
{
    A a(1);
    A b(2);
    swap(a, b); // OK
    return 0;
}

但更简单的方法是采用智能指针:

using std::swap;
class A
{
    std::unique_ptr<int> p;
public:
    A(int value) : p(std::make_unique<int>(value)){  };
};
int main()
{
    A a(1);
    A b(2);
    swap(a, b); // OK
    return 0;
}

而且,在类中使用智能指针替代原始指针还有一个好处:避免了构造过程中可能出现的异常导致的资源泄漏。 有关详细信息,请参阅 Scott _More C++ _Item 10。

关于智能指针,有机会我会单独写一篇文章。

检测类的各种构造函数状态

头文件提供了这样的功能。

#include 
#include 

using namespace std;
struct ClassA
{
    int n;
    ClassA(ClassA &&) = default;
};
struct ClassB
{
    int n;
    // 标记为可能抛出异常;
    ClassB(ClassB &&) noexcept(false){};
};
int main()
{
    cout << boolalpha
         << is_move_constructible<ClassA>::value << endl
         << is_trivially_move_constructible<ClassA>::value << endl
         << is_nothrow_move_constructible<ClassA>::value << endl
         << is_move_constructible<ClassB>::value << endl
         << is_trivially_move_constructible<ClassB>::value << endl
         << is_nothrow_move_constructible<ClassB>::value << endl;
    return 0;
}
/*
运行结果:
    true
    true
    true
    true
    false
    false
*/

e、用于检测类型是否可以移动和构造;

,用于检测类型是否有正常的移动构造函数。 为了满足“正常”特征,我们需要确保类型满足多个约束。 例如,类型没有虚函数、没有虚基类、没有不稳定的非静态成员等。

,用于检测类是否具有不抛出异常的移动构造函数。 替换为 ,可用于检测移动赋值运算符。

合理利用移动操作可以让我们开发的应用享受到“资源移动”而不是“资源复制”带来的高性能。 但不合理的使用可能会导致程序内存泄漏、数据丢失等。因此,在设计类结构时,需要提前考虑类的各种使用场景、对象之间数据的流向以及对应的类结构。处理方法,并采用相关设计规范。

特殊成员函数生成机制总结

(摘自Scott C++,第17条:理解特殊成员函数的生成机制,有删节)

当对数据成员或基类使用移动构造或移动分配时,不能保证移动实际上会发生。 事实上,逐个成员的移动更像是逐个成员的移动请求,因为对不可移动类型使用移动操作实际上执行复制操作。 逐个成员移动的核心是对对象使用std::move,然后函数在决定时会选择执行移动还是复制操作。

这两个复制操作是独立的:声明一个操作并不限制编译器声明另一个操作。 如果您声明了复制构造函数但未声明复制赋值运算符,并且您编写的代码使用了复制赋值,则编译器将帮助您生成复制赋值运算符。 同样,如果您声明复制赋值运算符但没有声明复制构造函数,并且代码使用复制构造函数,则编译器将生成它。

这两个移动操作不是独立的。 如果声明一个移动函数,编译器将不会生成另一个移动函数。 此规则的原因是您实际上表明移动操作的实现将与编译器生成的默认按成员移动移动构造函数有所不同。 如果逐个成员的移动构造操作不适用,那么逐个成员的移动赋值运算符很可能是不合适的。 总之,声明移动构造函数将阻止编译器生成移动赋值运算符,声明移动赋值运算符还将阻止编译器生成移动构造函数。

此外,如果一个类显式声明了复制操作,编译器将不会生成移动操作。 对于此限制的解释是,如果声明复制操作意味着默认的逐个成员复制操作不适用于该类,则编译器将理解,如果默认的复制操作不适用于该类,则移动操作也可能不适用。

一旦声明了移动操作(通过移动构造或移动赋值),编译器就不会生成复制操作。

这导致了我们下面讨论的内容:三法则:

如果声明复制构造函数、复制赋值运算符或析构函数之一,则还应该声明其他两个。

通过长期的观察得出,用户需要接管复制操作几乎都是因为类会做其他资源的管理,这也意味着(1)无论哪种资源管理都可以在类内完成一个复制操作,也应该在另一个复制操作内完成; (2)类的析构函数也会参与资源管理(通常是释放)。

三巨头规则的推论是,用户定义的析构函数的存在意味着简单的逐个成员复制操作不适用于该类。 从这个推论可以得出,如果声明了析构函数,则不应自动生成赋值操作,因为它们不太可能正确运行。 不过,在C++98时代,这种说法并没有受到重视,因此用户声明的析构函数不会影响编译器自动生成复制操作。 这种情况一直被保留下来。

但三巨头定律的理由仍然成立,所以在C++11中规定:只要用户声明了析构函数,就不会产生移动操作。

产生移动操作的条件为(仅当以下三个同时为真时):

有一天,这一机制也将扩展到复制操作,因为 C++11 标准规定,当复制操作或析构函数已经存在时自动生成复制操作已被弃用。 要么重新实现复制操作,要么假设编译器生成的函数具有正确的行为,在这种情况下,应使用 = 显式表达此想法。 这种方法在多态基类中通常很有用。

综上所述,C++11处理特殊成员函数的规则如下:

成员函数模板的存在并不妨碍编译器生成特殊的成员函数。

标签: 函数 操作 复制

如本站内容信息有侵犯到您的权益请联系我们删除,谢谢!!


Copyright © 2020 All Rights Reserved 京ICP5741267-1号 统计代码