现代C++学习——类和面向对象基础补充
本文最后更新于 315 天前,其中的信息可能已经有所发展或是发生改变。

一段适合初学者的引入

  1. 面向对象和面向过程是一个相对的概念

  2. 面向过程是按照计算机的工作逻辑来编码的方式,最典型的面向过程的语言就是c语言了,c语言直接对应汇编,汇编又对应电路

    因为严格按照计算机的工作逻辑,就必须服从内存层面的限制。而变量存放在栈或堆区,函数存放在代码区,导致体现数据封装的结构体和逻辑封装的函数,必须分开声明,所以无法直接实现类。这是面向过程的本质

  3. 面向对象则是按照人类的思维来编码的一种方式,C++就完全支持面向对象功能,可以按照人类的思维来处理问题

  4. 举个例子,要把大象装冰箱,按照人类的思路自然是分三步,打开冰箱,将大象装进去,关上冰箱

    要实现这三步,我们就要首先有人,冰箱这两个对象。人有给冰箱发指令的能力,冰箱有能够接受指令并打开或关闭门的能力

    但是从计算机的角度讲,计算机只能定义一个叫做人和冰箱的结构体。人有手这个部位,冰箱有门这个部位。然后从天而降一个函数,是这个函数让手打开了冰箱,又是另一个函数让大象进去,再是另一个函数让冰箱门关上。

    面向过程每一步都是上帝在操作数据,而面向对象把动作下放到了数据上

    从维护角度,你更愿意开发一个全知全能的上(shi)帝(shan),还是开发一群独立个体呢

    从开发者的角度讲,面向对象显然更利于程序设计。用面向过程的开发方式,程序一旦大了,要从天而降引入一个函数,想想都头大,一些纯c写的大型程序,也是模拟了面向对象的模式

    那么,如何用面向过程的c语言模拟出面向对象的能力呢?类就诞生了,在类中可以定义专属于类的函数,让类有了自己的动作。回到那个例子,人的类有了让冰箱开门的能力,冰箱有了让人打开的能力,不再需要天降神秘力量了

构造函数和析构函数

我们从如何将结构体变成类出发,毕竟高级的东西也得从机器本身出发

结构体本身只是划定一块连续内存给若干变量,如何将这一块内存组织成一个整体而非单纯的一块内存呢?

构造函数

对象生成时就会调用来初始化,可设定参数

即使没有定义,类也至少有 默认构造函数默认复制构造函数(还有 默认赋值运算符

  1. 默认构造函数(无参,当无构造函数时,会生成一个,且不执行内存初始化)

    Test() {}    // 按理说就长这样,但如果你自己写出来了,那也不能算默认了,归为自定义的普通构造函数 (文字游戏)
  2. 普通构造函数(人为定义的且不为特定参数)

    Test(int i1_, int i2_): i1(i1_), i2(i2_) {}      // 初值列,默认学过
  3. 复制构造函数(参数为同类引用常量,不定义时也会有一个,默认执行变量浅拷贝逻辑)

    Test(const Test& test): i1(test.i1), i2(test.i2) {}
    • 如果参数没有修改,建议加上 const
    • 复制构造必须传引用,否则仍然需要通过复制来传值,又要调用复制构造,就是死循环
  4. 移动构造函数(后面会详解,简单说就是:不是复制,而是剪切,类似转交内存,更加高效)

析构函数

对象销毁时就会调用,无参无返回值不可 const,故不可重载(但可以多态)

  • 当需要手动善后时,就需要定义析构函数。如果不定义,也会生成一个空析构,里面啥也不干

    释放指针只是一个简单的例子,到了后期,网络和 IO 流等,必须充分利用好析构的机制

    // 例: 必要的手动析构
    class Test{
    public:
      int* pi;
      Test(int i) : pi(new int(i)) {}
      Test(const Test& test): pi(new int(*test.pi)) {}
      ~Test(){
          delete pi;
      }
    };

this 关键字,常成员函数和常对象

this 是一个很重要的关键字,而后两个概念也与之相关,所以特地放在一起,看完就懂了

this 关键字

this 关键字并不陌生,在类方法中表现为指向当前对象的指针

类函数访问属性,实际上都是隐式使用了 this -> 属性,不过绝大多数情况下可以省略

少数情况比如变量同名时就不行,this 可以告诉函数,调用函数的是某一个对象

如果 this 真是指针,那不就揭示了用 C 语言实现结构体和函数绑定的奥秘?

如果你这么想,那还为时尚早

写到这里有感而发,建议读的时候略过:

说到 this 就有 this 底层是指针啥的以及衍生的说法

但是,学习 C++ 最重要的一点是,作为 C++ 的开发者,如果干的不是底层的活,不需要底层的优化和设计思路,就请专注于已搭建好的上层特性

别把编译器奉为圣经,不断地把底层和 C++ 特性直接挂钩,请问这个过程中你得到了什么?就像用物理解释化学(不一定恰当)一样,走进失去 C++ 本身意义的死胡同。以此类推,任何语言都会失去意义,因为最后都是 C 语言

学习 C++ 乃至整个编程的体系,都有一个重要的东西叫做透明,它的直接目的就是防止底层和上层的设计思路用串了、产生不必要的等效替代。于是开发者才能专注于自己正在做的东西

当然不是说不要看底层实现,只不过这和学习语言本身并不是一回事,类似先射箭后画靶的感觉。拿个底层实现说自己学会了、看透了 C++ 是不明智的,也不能说是错的,只能说这东西不存在绝对的定律

还记得一开始说的吗?this 是关键字

既然是关键字,便不能等同于指针变量,而应该说是指针特性的超集

当然,代码在编译期间,this 的指针职能多半还是通过一个指针实体实现

但,这时候的代码已经不是 c++ 了,与 c++ 语法又有什么关系呢

常成员函数

C++ 类成员属性也是结构体,成员函数能通过 this 或指针操作属性,有时是不安全的

对于类指针,我们可以直接定义 const Test* const 保证属性的安全,那么我拿出 this 阁下又该如何应对?

于是便有了常成员函数 Test::func() const {}。虽然不严谨,但中间的 const 可以理解为对 this 的限制

很喜欢 C++ 的一点,学了一段时间就会发现,有些语法是针对另一个个语法的漏洞打的补丁(笑,无恶意)

除了限制 this 外,为了防止打破常函数的安全性,常函数内不能调用非常函数,反之则可以

  • 不论是 IDE 还是前辈,都会建议你:不修改属性的成员函数一律设为常函数(属性同理,无所谓,波浪线会出手)

从常函数的角度看,构造和析构修改了属性,静态成员函数没有 this,因此都不能设为常函数

  • PS:同名同参数单纯只加个 const 也算重载,且,普通对象会优先调用普通重载,常量对象优先调用常重载

    关于重载,个人认为比较确切的定义是:保持名字一致情况下,新的函数会对外界产生不同的影响

    根据这条定义,符合重载的形式有:

    1. 指针 ↔ 指针常量
    2. 函数 ↔ 常函数(可以归入 1)
    3. 参数不同(在参数变量前加 const 其实和换个参数名一样,没意义,因为不会向外界反馈不同结果)
常对象

如果整个对象都没有修改需求,按照常变量原则,可以直接创建常对象,也没啥可以多说的

常对象是完全不变的,为了安全,常对象不能调用非常函数

  • 常对象作为一个不变的整体,少了很多要考虑的逻辑,会被编译器显著优化,如果可以的话,多用常对象

    回顾过去的代码,其实很多对象都是当成打包的常量+配套函数使用的,所以这是强烈建议养成的好习惯

几个关键字

inline 内联

在早期 C++ 程序中,执行函数需要新开栈,造成一定开销,而 inline 关键字可以在编译时将函数内部逻辑直接放到外部(如果可以实现的话),减少一层栈

但如今,inline 导致的外部程序过大,带来的缺点可能大于减少栈的优点,所以是可选项(现代编译器,即使加了 inline 也会自动判断是否合适)

以下代码展示了 inline 的几个特性

class Test{
public:
    void fun(){
        cout << "fun()" << endl;
    };  // 直接在类中声明且定义,自动视为inline (因为这样的函数一般都很短,适合内联)
    void gun(); // 声明处不必加inline,但可以增加可读性
}
inline Test::gun(){ // 定义处必须加inline
    cout << "gun()" << endl;
}
mutable 可变

mutable 修饰的变量可以无视函数的 const 限制被修改,如常成员函数

mutable 并不是类型的一部分,是让非常成员变量,在对象视为 const 时不受影响

这关键字意义不明,其唯一指定用途,就是统计常函数的调用次数,此外都是野蛮的,都是在背刺前面写的内容

  • 注意:mutable 是针对某一个非常对象的,因此不能修饰常成员变量和不属于对象的静态成员变量
default 默认

default 用赋值语法为某些成员函数指定默认函数体(包括默认构造,复制构造,operator= 和析构函数)

用在其他无默认形式的函数会报错

default 的意义是:既能显式定义默认函数,又十分简洁,从两重意义上提高了代码可读性

class Test{
public:
    Test() = default;
    Test(Test& test_) = default;
    Test& operator=(Test& test_) = default;
    ~Test() = default;
}
delete 取消

delete 用于取消函数的定义,从而不被编译,可以用于各种成员函数

delete 可以取消编译器自动生成的函数,如默认成员函数老四样,以及取消数值型参数的隐式重载

例如:通过取消复制构造实现单例模式

  • 注意:析构函数不要 delete,虽然语法不会阻止这样做
class Test{
public:
    Test(Test& test_) = delete;     // 禁止复制构造
    void fun(int i){}
    void fun(double i) = delete;    // 可以阻止参数从double到int的隐式转换,但用处不是很大
}

友元

在类中声明 友元类 / 友元函数,被声明者可以直接访问该类的私有成员

  • 如果搞不清关系,很简单,我对某个目标声明友好,所以我对他开放我的私有成员

问题是,只是为了访问私有成员,友元还会破坏封装性,完全可以用 gettersetter 封装实现

所以 friend 除了某些特定场合(流运算符 << >> 重载)都没用

友元没有公有私有概念,可以声明在类中任何位置,语法展示如下 ↓

class Test{
    friend class Tool;  // 友元类(class也可以隐式)
    friend void fun(int);   // 友元函数(外部函数)
    friend void Tool::fun1();
    friend Tool& Tool::operator=(const Tool&);  // 这两行表明(应该)什么函数都能声明成友元
}

冷知识:一个类对自己可以视作友元(便于理解),总之可以随便访问同类对象的私有成员

运算符重载

C++ 为类提供了 “自定义运算符行为” 这一高度灵活的功能,统一以 operator运算符 为函数名的形式实现

重载运算符在后期是每个类的标配

注意:

  1. 不能重载 C++ 不支持的运算符,比如 python的 **

  2. 要了解运算符的元数。或者说,运算符用法决定了参数和返回值的定义

    • 比如返回值和调用者同类型,才可以支持连续使用运算符(很多运算符都有)

    • 比如数值意义的运算符,一般更适合返回临时对象值,而非引用(并非性能原因,更多是安全原因)

    • 唯一的三元运算符 ?: 无法重载,所以都是一元和二元

  3. = 有默认重载

  4. 运算符重载不一定是成员函数,也可以是外部函数,首个参数是指定调用的主体,来取代成员函数的隐式 this ,在外部重载后,其他类一般可以将其声明为友元函数,并使用

  5. 除了运算符本身的语法,也可以当作函数,直接 .operator运算符(参数) 来使用(我只能说一般很少这样干)

一元运算符

++ -- [] () << >>

  • 其中 << >> 特殊,因为流运算符的调用主体必须是流对象,而成员函数的调用主体都是 this

    • 所以重载不能成为当前类的成员函数

    • 又不好直接修改标准库的流类,所以就作为当前类的友元在外部重载了,没有 this,第一个参数为流引用

二元运算符

+ - * / = > < ==

类型转换运算符

operator 类型

特殊运算符

new delete new[] delete[]

class Test{
    // 流对象会改变,不能为常量
    friend std::ostream& operator<< (std::ostream& os, const Test& test);
    friend std::istream& operator>> (std::istream& is, Test& test);
public:
    std::string name;
    void operator++(){}
    void operator()(const std::string& str) const {
        std::cout << str << std::endl;
    }   // 参数作仿函数参数
    int operator[](unsigned i){}

    Test& operator= (Test& test){
        if(this == &test){
            return *this;   // 根据地址判断自赋值
        }
        name = test.name;
        return *this;
    }
}

std::ostream& operator<< (std::ostream& os, const Test& test){
    os << test.name << std::endl;
    return os;
}   // 定义该类的输出规则

普通继承

继承,面向对象三大特性之一,一方面可以抽象共性便于编程,一方面可以在类之间建立联系,便于边扩展边维护

  • 有过简易游戏引擎的经历,所以对此深有体会

关于继承的种类,简单说就是限定了子类继承成员时的权限上限,高于此关键字的都会降权限

  • 补充一句:protected 同样不可被外界访问,但权限略高一级,相当于可以被继承的 private

public 的结果是不改变任何权限,也是一般(99%)情况使用的

protected private 在业务代码里因为加了多余的访问限制,基本是不考虑的,但在标准库中利用访问权限的特性设计模板类和函数,使用率就显著上升

class Spear{
public:
    std::string name;
}
class FireSpear: public Spear{
public:
    void attack();
}
  • 冷知识:继承在内存中的体现,子类的内存块头部是父类的结构,紧接着是子类的结构,因此解释了以下特性:
    1. 子类的构造,会先进行父类的构造,再构造自身
      • 如果子类构造中没有调用父类构造,则使用父类默认构造。此时如果父类默认构造被取消了,会无法创建子类对象
      • 子类继承了父类的构造,所以子类的默认构造要手动补上,不会自动生成
    2. 子类的析构,会先析构自身,再调用父类的析构
    3. 父类的指针,可以指向子类的对象,且因为指针的类型是父类,只能控制属于父类的那部分

多态,虚函数,override 关键字

多态

继承解决了变量的抽象,但父类的抽象与子类对函数的的扩展产生了矛盾。当使用父类指针指向子类对象时,可以使用子类的属性,但不能使用子类重写的函数

C++ 提供了虚函数与 override(重写),实现了从父类指针接受不同子类对象调用不同逻辑

// 示例: Spear的子类可以重写attack()方法, 调用自己的方法后释放内存(或使用智能指针)
void attack(Spear* p_spear){
    p_spear -> attack();
    delete p_spear;
}
虚函数

形式:virtual 函数声明

编译时,父类指针调用函数的语句,会找到父类中的声明,根据其是否为 virtual 绑定到指定函数体

  1. 子类重写普通函数,调用的函数体由指针类型决定(父类函数)

  2. 子类重写虚函数,调用的函数体由指针实际指向的对象决定(子类函数)

注意:

  1. 重写务必保证函数名,类型,const 等完全一致。但因为虚函数检查在父类,子类可以选择省略 virtual

  2. 父类的析构函数必须为虚函数,因为要保证子类销毁时一定调用子类的析构,不能被父类指针影响而泄露了子类部分的内存(而且排查起来也麻烦,血泪教训)

    代码中的析构和 virtual 经常是成对出现,毕竟自己写析构经常意味着手动管理内存

虚函数原理
静态绑定 & 动态绑定

编译时,每个函数调用要绑定到一个跳转地址

  • 静态绑定:没有多态的普通函数,始终绑定在固定地址

  • 动态绑定:对于重写虚函数,如何实现调用时绑定到具体的不同地址?C++ 实现了一种类似类型检查的功能

    编译期,使函数调用绑定到对象虚函数表的内容上,由对象的虚函数表来决定函数地址

虚函数表

涉及动态绑定的类,每个类有一个虚函数表,存放了它的虚函数的实际地址,可以说是一个指针数组

虚函数表于编译器生成,位于进程内存的数据区

该类每个对象有一个虚函数表指针,位于其内存块头部,所有成员变量之前,指向该类的虚函数表

因此,父类指针指向的子类对象,其虚函数表为子类的虚函数表,调用的函数地址为表中的实际函数地址

override

C++11 提供的类似注解的关键字,①辅助程序员检查函数重写是否正确,②提高代码可读性

virtual void attack() const override {}

静态成员变量和静态函数

静态成员变量

静态成员变量与静态外部变量同属于静态变量区,具有静态变量的内存特性,但须体现封装性

  • 因为编译期间就要初始化,静态成员变量必须在类外初始化,通过类名进行

  • 静态成员变量有超长的生命周期,只要权限够都能访问,即使没有对象。便有 静态成员变量属于该类 的说法

    或者说 属于该类和所有对象公有,对象和非静态成员函数也都能访问(只要权限够)

静态成员函数

静态变量诞生的意义是为类和所有对象公有的变量

所以静态函数诞生的意义是为类和所有对象公有的封装性(熟悉一下特殊语法,反正也不是总用到)

由于每个对象对静态成员函数,仅有使用权但没有所有权,也可以说 静态成员函数也属于该类

  • 而且静态成员函数无法具有 this

    以至于其无法隐式访问所有非静态成员变量,只能访问静态成员变量;也不能定义为常函数

class Test{
public:
    static unsigned i;
    static int get_pi(){
        return pi;
    }
private:
    static int pi;
};
unsigned Test::i = 20;  // 类外初始化
int Test::pi = 20;  

int main(){
    std::cout << Test::i << std::endl;  // 公有静态变量,不利于封装性
    std::cout << Test::get_pi() << std::endl;   // 通过函数访问私有静态变量值
}

纯虚函数

如果一个类只有虚函数,可以直接声明 函数声明 = 0 成为纯虚函数,此类称为纯虚类

纯虚函数无需在基类中实现函数体,全在子类中实现。纯虚类也被规定不能实例化,只能使用指针

纯虚函数的使用频率还是很高的。这种类在 Java 里被单独拎了出来,叫做接口

class Test{
public:
    virtual void action() const = 0;    // 纯虚函数的声明(可以不实现)
}

RTTI 运行时类型识别

输入一个对象,输出对象类型相关信息

可以用于指针或引用目标不确定的场合,是 C++ 判断当前父类指针指向实际类型的唯一方法

使用场景:①异常处理;②IO 操作

方法:

  1. typeid(对象) 返回一个 type_info 结构体,包含类型相关的各种信息,以及 name() 方法返回类型字符串

  2. 使用 dynamic_cast<子类指针类型>(指针) 可以将父类指针转换为子类指针,仅当父类指针指向子类对象时可行,转换失败返回 nullptr

    冷知识:C++11引入 nullptr 的原因:

    • C++中 NULL 的定义局限性是 C 的历史遗留问题:宏定义以及传统类型

    • C 语言中的宏定义 NULL(void *)0,由于 C 的精髓是接近硬件,指针给的限制少,也没有重载语法,因此 NULL 可以正常用于各种指针的空数据

    • 随着 C++ 的指针限制,类型的问题体现出来:

      C++ 不接受不安全的 void 向任何类型的隐式转换,NULL 值不能传给其他类型指针,更不能传给普通变量,所以就废了

    • 于是,C++ 选择了直接定义 NULL=0 这一权宜之计,但 C++ 支持重载,带来第二个隐患:

      • 参数类型歧义(当参数仅符合数量但无匹配类型时,依然会尝试转换类型),而此时 NULL 默认为整数
    • 最终 C++ 用自己的方法解决问题,保留了 NULL 为 0,定义了 nullptr 专用于不会被转换的空指针

      • (猜猜怎么实现类型转换?我觉得模板可以一战,不过 nullptr 本身是个关键字。这里放一段后来找到的模拟nullptr功能的代码)
      struct nullptr_t {
         // 定义一个函数指针转换操作符,可以将 nullptr_t 转换为任何类型的空指针
          template
          operator T*() const noexcept {
              return 0;
          }
      
          // 这是一个辅助的成员模板,用于解决nullptr_t到任意类型的转换问题
          template
          operator T C::*() const noexcept {
              return 0;
          }
      };
      • nullptr 主要是替代宏定义,内部逻辑说到底还是解决 0 的转换(偶尔用打补丁的思路看语法,就不难

注:

  • typeid 支持对基本类型的判断,这个一般编译时就被优化了

    只有多态的指针,才会在运行时实时判断类型。传入 nullptr 时会出现 bad_typeid 异常

  • typeid().name() 输出形式可能因编译器而异,如 msvc 输出就是声明类型,如 int struct A class B

  • dynamic_cast 仅可用于继承关系转换,非强制转换

int main(){
    Spear* p_spear = new Spear();
    // 转成标准 string 来比较
    if(std::string(typeid(*p_spear).name()) == "class FireSpear"){    // 获取结果: class 类名
        FireSpear* p_fire_spear = dynamic_cast<FireSpear*>(p_spear);  // 向下转换为该类型指针
        if(p_fire_spear){   // 非nullptr
            // 使用指针, 略
        }
    }
}

基本操作很简单,后面看看实际应用

多继承

多继承的类,与继承同理,其内存头部依次排列各个基类的成员变量

执行构造也是按继承顺序最后到自己,析构也是从自己开始反过来逐个析构

class Base1{
public:
    Base1(int i1_): i1(i1_) {}
    int i1;
}

class Base2{
public:
    Base1(int i2_): i1(i2_) {}
    int i2;
}

class Test: public Base1, public Base2{
public:
    Test(int i1_, int i2_, int t_): Base1(i1_), Base2(i2_), t(t_) {}    // 在初始化列表中构造两个父类
    int t;
}

注意:

  1. 多继承可能从不同父类中重复继承了相同的变量,调用时产生二义性
  2. 因为是直接继承多个类,分析起来较为复杂(实际上多继承主要应用就是接口模式,反观 Java 取消多继承而定义了接口,与类区分开来,使得运行时类型检查也更方便)

虚继承

菱形多继承会产生相同变量二义性问题,子类中同时存在来自两个继承路径,最终源自同一个基类的变量

如下图,Base::i 在内存中有两份,访问就会歧义

graph TD
Base
Base --> Base1
Base --> Base2
Base1 --> Test
Base2 --> Test

这里的歧义,特指 Base1 Base2 中同继承于 Base 的变量

如果只是各自定义的同名变量,并不会歧义,因为可以用 t.Base1::i 形式区分来源

  • 这种情况在游戏开发中出现较多,例如一个实体类可能继承自多个标签,而不同标签可能继承了相同的纯虚类

原理:

  • 当一个类使用虚继承,会用一个虚继承表存放父类成员变量的偏移地址(?存疑)

    虚继承的类被继承时,会逐一比对偏移地址,从而保证同源的变量在目标子类中只继承一份

  • 注意,虚继承应发生在变量被歧义继承时,需要定位歧义的变量,在子类声明中使用 virtual 继承,如下:

class Base0{
public:
    int i = 0;
};

class Base1: virtual public Base {
public:
    Base1(const int i) : i(i){}
    int i = 10;
};

class Base2: virtual public Base {
public:
    Base2(const int i) : i(i) {}
    int i = 20;
};
// 先保证根源类的子类都是虚继承,他们才能为子类提供不重复的成员变量
class Common: public Base1, public Base2 {
public:
    Common(const int i): Base1(10), Base2(20), i(i) {};
    int i = 30;
};

Common common(0);
std::cout << common.Base0::i << std::endl;  // 0

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

移动是一种类似剪切的操作

移动赋值和拷贝不另外构造对象,而是直接将对象内存的访问权限转交(在 Rust 中也体现的淋漓尽致)

适用于没有保留需求的数据转移,将被复制的对象作为右值,对于大型类,可以巨大提高性能

当对象被移动给另一个对象后,原有的对象就不能再使用了

class Test
{
public:
    Test() = default;

    Test(const Test& test){
        if(test.str) {
            str = new char[strlen(test.str) + 1]();
            strcpy_s(str, strlen(test.str) + 1, test.str);
        }else {
            str = nullptr;
        }
    }

    Test(Test&& test) noexcept{
        if(test.str) {
            str = test.str;
            test.str = nullptr;
        }else {
            str = nullptr;
        }
    }

    Test& operator=(const Test& test){
        if(this == &test) {
            return *this;
        }
        if(str) {
            delete[] str;
            str = nullptr;
        }
        if(test.str) {
            str = new char[strlen(test.str) + 1]();
            strcpy_s(str, strlen(test.str) + 1, test.str);
        }else {
            str = nullptr;
        }
        return *this;
    }

    Test& operator=(Test&& test) noexcept{
        if(this == &test) {
            return *this;
        }
        if(str) {   // 赋值非构造,要先清理现有字符串
            delete[] str;
            str = nullptr;
        }
        if(test.str) {
            str = test.str;
            test.str = nullptr;
        }else {
            str = nullptr;
        }
        return *this;
    }
private:
    char* str = nullptr;
};

补充:

  1. strlen( ) 返回的字符串长度不包括末尾字符,所以要 + 1

  2. strcpy_s(Dest, length, Src)strcpy 的安全版本,相比后者增加了长度,保证复制时,有足够大缓冲空间存放被复制的内容

    • 以上两个函数都不属于 namespace std
  3. 暂时不用的指针要赋 nullptr 防止悬空,指针转移时要及时释放旧内存(这个需要多写熟练)

    因为要将目标指针置 null,所以参数不能为 const

默认移动构造 / 默认移动赋值

当一个类没有任何自定义拷贝构造和赋值,且非静态成员都可移动,系统会生成默认移动拷贝

  • 注:一定是没有任何,定义了赋值也会影响生成默认移动构造(直接隐式 delete)
可移动

可移动即支持移动语义

基础类型都可移动,有移动语义的类也可移动

关于移动语义的补充

移动语义是 C++11 的重要革新,同时将右值的定义与移动语义结合起来

  1. 移动语义和所有权是干嘛的

    引用知乎上的话:移动语义的诞生,就是方便区分这个到底是转移所有权,还是进行复制

    在 C++11 以前,转移数据的方式还很原始,没有一步到位的所有权转移,只有复制+销毁的原始方法

    因此产生的性能浪费有几个典型的例子:

    • 函数的返回值对象被赋值,其中的所有权转移,需要调用复制构造

    • STL 容器的转移,除了本体外,每个元素都要调用复制构造

    显然这些复制都是虚假的复制,都是复制+销毁,本质分明是所有权的转移,完全可以在底层改为指针的移动

    这就是移动语义的初衷,也是为何右值是“抛弃了”地址属性的值,因为不需要

  2. 移动语义真的“物理”移动了对象吗

    事实上,我们一直强调的是,不允许再使用被移动的对象,以及在实现移动构造时,都会手动将旧对象置 null

    实践证明,所有权显然不等于数据本身,被移动的对象确实仍然能用(很符合我对 C++ 的想象)

    STL 容器以及智能指针等类,也都是实现好了移动的逻辑,比如智能指针会置空指针,而且也仍可以调用函数

    所以这是一个君子协议。如果自定义类的移动构造没有置 null,或者干脆是移动基础类型,就更能体现这一点了

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇