Aquacolor

Aquacolor



新坑【5】

Gumdrop · 2025-08-25 · 23浏览 · 未分类


(其实本来昨天的内容也是打算从难的部分开始往前写的但是难的部分太难了导致写不了一点)

  1. 平凡类 平凡类(trival class)是具有标准空间布局的类型,这种标准要求满足挺多条件,但是可以用<type_traits>标头带的模板函数判断是否平凡。 也提出一个平凡数据的概念,包括平凡类和内置类型(除指针和常量)

    1. 所有非静态成员变量平凡,基类平凡

    2. 无虚函数、虚基类,因为会在对象内存空间加一vptr

    3. 有默认的无参构造、拷贝构造、移动构造、拷贝赋值、移动赋值函数,通常也要求默认析构函数

    4. 不含自定义的构造函数和赋值函数。

    5. 不含有非静态引用成员和非静态常量成员。

    可以看出有静态成员和非虚成员函数不会影响类的平凡性,在类中使用using-declaration、成员模板也不会。 (成员变量模板是不被允许的好像,它会导致实例化的一个类型可能具有不确定大小。所以请使用依赖类型指针) 平凡类/平凡数据的默认空间布局使得:

    1. 它可以很便利的作为c风格的数据使用

    2. 支持聚合体初始化和赋值

    3. memcpymemmove快速复制和移动,而不用调用

    (当你想要提升c++的执行效率时,你使用的数据结构只能接近c……,即Plain old data,POD)

  2. 有关继承 继承是面向对象的重要工具,提供了一种实现IsA逻辑设计的方式 (通常在继承关系中使用基类和派生类的概念,并可以推断类对象的大小)

    1. 继承 派生类对象内存的前面是一个基类对象的内存,后面是派生类的新成员的内存。 这意味着派生类对象并不能通过成员访问的方式返回一个基类对象,但它在大多数情况下可以隐式转换成基类类型,并可以直接通过成员访问的方式使用基类成员。 保持c++标准内存布局。

      class Base{
      public:
          int i;
          static int si;
      }
      
      class Derived:public Base{
      public:
          int j;
      }
      
      Derived derived;
      Derived* derivedptr = &derived;
      
      derived.Base::i;
      derived.Base::si;
      derived.i;
      derived.si;
      derived.j;
      derivedptr->Base::i;
      derivedptr->i;
      derivedptr->j;
      //受限查找更加安全,但是非受限查找也有自己的语义。
      
      Derived::si;
      Derived::Base::si;
      //::作用域限定符也和.和->访问一样,
      
      Base* baseptr = &derived;//安全隐式转换
      baseptr->i;//直接成员访问
      static_cast<Derived*>(baseptr)->j;//需要指针类型转换
      //和基类型的关系就是IsA
      
    2. 覆盖和重写 覆盖(override)和重写/隐藏(overwrite/hide)是类继承中常见的两种情景。 (ai会混用重写和覆盖所以还是注意着点)

      1. 重写/隐藏 (这里同时使用隐藏、重写来表示该特性,基类成员被隐藏,派生类重写基类成员) 隐藏发生于同名而非重载名称被非受限查找时。静态成员也会被隐藏。

        1. 同名成员变量 只要是同名的成员变量出现,派生类的成员变量都会隐藏基类的。此时派生类成员变量可以是任何类型。但是基类依然存储了同名的一个成员变量。

        2. 同名同参数表成员函数 当派生类定义了同名函数且参数表与基类成员函数相同,此时不能发生函数重载,于是基类函数被隐藏。但是代码区依然存在该基类函数。

        这个特性来源于非受限查找的作用域规则:先在本类作用域查找,再扩展到基类。 但是注意如果用基类指针指向一个派生类对象,不能使用派生类的成员。

      2. 覆盖 覆盖来源于虚函数。

        1. 虚函数 用virtual修饰的函数。当一个类中有虚函数时,它将在对象内存开头创建一个vptr指向一个vtable。 vtable是一个存储该类型所有虚函数指针的表,属于类型而非对象。 这是运行时多态的基础。

          1. virtual不能修饰成员变量,因为如果这么做,基类指针将可能通过vtable访问未定义的内存区域。

          2. virtual不能修饰成员模板,因为成员模板可能进行多次实例化,将导致vtable不具有固定大小。

          3. virtual不能修饰静态成员,因为在对象创建前vptr不存在。

          派生类用虚函数实现覆盖时,一定和基类虚函数有同类型函数指针,这意味着返回值类型也要相同。 通常在派生类中用override表示该函数覆盖了基类函数。

          virtual void func(){...}
          void func() override {...}//virtual可省略
          
        2. 覆盖 派生类的vtable表中,原基类虚函数的位置被派生类虚函数替代。运行时使用基类指针调用虚函数时,通过vptr访问了派生类vtable,所以达到了运行时多态的效果。 但是注意:

          1. 运行时多态只发生在用基类指针、引用调用虚函数时。 当用对象本身调用虚函数时,由于vptr和对象类型是绑定的,一个基类对象不应该访问派生类的vtable,所以虽然会通过vptr查找函数,但是不会发生多态。

          2. 受限查找不会通过vptr。 覆盖指的是vtable的虚函数指针覆盖,因此基类虚函数体还存在于代码区,可以通过受限查找直接调用。 由于作用域规则不会向派生类作用域查找,所以基类对象、指针、引用不能调用派生类成员。 但是指针、引用类型的强制类型转换是有定义的,所以可以用static_cast<>()进行。

          3. 编译器在高级优化模式下可能会在非多态时减少通过vptr调用虚函数的形式,这并不会影响运行结果。

          class Base {
          public:
              virtual void print() { cout << "基\n"; }
          };
          
          class Derived :public Base {
          public:
              void print() override{ cout << "派\n"; }
          };
          
          int main() {
              Derived d;
              Base b = d;
              Base& bl = d;
              Base&& br = move(d);
              Base* bp = &d;
              d.print();
              d.Base::print();
              d.Derived::print();
              b.print();
              b.Base::print();
              //static_cast<Derived>(b).Derived::print();不存在该强制类型转换
              bl.print();
            //bl.Derived::print();基类作用域内不能使用派生类作用域限定
              br.print();
              bp->print();
              static_cast<Derived&>(bl).print();//存在该类型转换
          }
          
    3. 抽象类和final类 实际上讲的是=0和final修饰符的作用。(因为放一起比较好记)

      1. =0 一个虚函数体可以用=0修饰,则这个虚函数成为纯虚函数。 有纯虚函数的类称为抽象类,特点是不能创建它的对象,且抽象类的派生类一定要声明基类的所有纯虚函数:

        1. 实现所有纯虚函数后,就可以创建派生类的对象。

        2. 将部分纯虚函数依然=0,则该派生类还是纯虚函数。

      2. final 被final修饰的类不能再被继承,被final修饰的虚函数不能再被覆盖。 (事实上是用来阻止开发者干不该干的事的)

        class FinalClass final{
            ...
            virtual void func() final {...}
            ...
        };
        
  3. 更多有关继承 使用继承的注意事项其实挺多,而且与逻辑设计紧密相关,但是总体来说就是少用虚函数和正确用继承。

    1. IsA和HasA/HoldA 这两种逻辑设计在语言上可以互换而实现上完全不相同。 IsA要处理基类部分,需要通过使用受限查找;hasA则通过成员访问。

      Base b;
      Derived d;
      
      d.Base::operator=(b);
      static_cast<Base&>(d)=b;
      //IsA处理基类部分的情况
      
      Owner o;
      Member m;
      
      o.d_m = m;
      //HasA处理成员对象
      
    2. using using-declaration只能向符合作用域规则的方向引入名称,并且不能与当前作用域的名称发生重复定义错误。 并且在当前作用域外就无效了

      1. 类内using-declaration using只能引入直接/间接基类中的成员,不能引入派生类成员。

      2. 命名空间内using-declaration using引入其他命名空间的成员到当前作用域。

      3. 使用基类构造函数的语法糖

        using Base::Base;
        
    3. 虚继承 这是为了解决菱形继承结构的多义性和空间占用问题。 派生类使用虚继承,将会有虚基类。虚基类部分在对象内存空间末尾,而在有虚基类的类部分开头是一个虚基类指针,指向虚基类部分。 这将导致非标准内存布局。

      1. 虚基类部分的初始化只会通过当前类型的构造函数,并且应该在非虚基类之前完成。

      2. 继承结构中含有虚继承的所有类都有一个虚基类指针vbptr。

    4. 状态的多重继承 这是导致二义性和空间占用的原因,状态其实就是非静态成员变量。 (成员函数的编译过程中,传入派生类的指针导致其实不会出现二义性) (当菱形结构出现时,通常很难保持类的平凡性了)

      1. 不再使用继承的方式

        1. 组合: IsA改为HasA/HoldA,并且保持A是同一个。

        2. 模板策略模式

        3. 类型安全联合: 用std::variant<>std::visit<>()代替基类,这将降低代码易读性。标头<variant>

        4. 类型擦除: std::any<>std::any_cast<>代替。标头<any>,显著降低运行速度。 标头<functional>也经常用来传递类型擦除的函数。

        5. 概念约束模板

      2. 还使用继承的方式

        1. 纯虚接口: 纯虚接口/无状态接口强调仅有纯虚函数/没有成员变量。再正常继承就可以了。 通常还需要禁用拷贝操作。 还可能会使用纯虚工厂的编程模式。

        2. NVI模式非纯虚接口: 公共非虚函数作为接口,其中调用纯虚函数,保护纯虚函数要求派生类实现。

        3. 模板递归模式

        4. 平凡接口 创建一个仅有非虚成员函数的类作为接口在语法上是可行的,但是有很多问题:

          1. 派生类如果没有重写接口函数,将会调用接口的无意义函数。

          2. 该接口的对象可能被创建,这将导致意外切片。

          3. 丢失运行时多态。但可以用类型安全联合达成。

          优势在于速度和通用性,并且不像类型安全联合丢失了继承信息,便于看出逻辑设计结构。

  4. c++程序内存布局 内存通常分为代码区、全局数据区、栈、堆。

    1. 代码区 已经知道类成员函数在内存中只有一份,这就是由于函数体都存储在代码区。 lambda表达式在c++中的实现利用了匿名类和括号运算符,所以也在代码区。 模板函数、类模板成员函数都在代码区。

    2. 全局数据区 有链接的变量名称,其值都存储在全局数据区。 无链接的静态变量也在数据区。

    3. 栈 不由程序员管理,通常定义的局部变量和对象、调用函数产生的栈帧都在栈中。 栈通常小点,不应该定义过大的局部数组就是这个原因。

    4. 堆 由程序员动态分配的空间,通过栈、堆或全局数据区里的指针管理。 来源是new。访问比栈慢但是空间比栈大。 c++标准内存布局,通常指的是没有特殊空间、操作都选用默认值、位于连续的空间中的内存布局。 位于连续空间是特别重要的。C数组、std::arraystd::vector通常位于连续内存中,这意味着如果要扩展它们的内存区域,需要重新new一个指针。 (所以我们要学会用vector::reverse()vector::emplace()

  5. 指针和类型转换 (这部分工具我希望没有用上的机会)

    1. 智能指针 (万恶的<memory>标头浪费我大一半个学期)

      1. std::unique_ptr<> 这个指针的特点是,存在一个unique指针就不会清理指向的空间,但是unique指针只能移动,所以只存在一个unique指针。 unique指针指向一个临时变量,将会延长它的生命周期。 它的构造建议使用std::make_unique()进行,当然也可以用一个右值指针调用构造函数。 (其中也用了完美转发) 总结为独占所有权,相比原指针基本没有性能开销。

      2. std::shared_ptr<> 这个指针就不是独占所有权的了,用计数的方式实现共享所有权。 用于不能够独占的情况,即需要多个指针。

      3. std::weak_ptr<> 这个指针不具有所有权。 用于循环指向中,指向一个shared指针。 或者用于表示一个仅用于观察的指针。

      MyClass myclass;
      auto ptr = make_unique<MyClass>(myclass);
      auto ptr2 = make_unique<MyClass>(new MyClass);
      unique_ptr<MyClass> ptr3(new MyClass);
      //unique指针,如果直接调用构造函数初始化,它要接受一个右值指针。
      //高版本建议使用make_unique函数模板创建unique指针,可以接受左值或右值参数,更好用。
      //shared和weak指针同理。
      
    2. 强制类型转换 在高版本的强制类型转换符出现后,之前的强制类型转换和隐式转换越来越变成一个不太好的性质了。

      1. 原本的强制类型转换 其实是两种。

        MyClass(...){...}//构造函数形式
        operator MyClass(...){...}//转换函数形式
        
        MyClass(1);
        (MyClass)1;
        //不用考虑找不找得到的问题,写程序一定要能找到
        
      2. static_cast<> 最常用的强制类型转换。 static_前缀表示静态,即编译时完成转换,没有RTTI(运行时)开销。

        1. 内置数值类型转换: int、double等。值得注意的是enum类型,enum存在隐式转换到数值类型的情况,高版本建议使用enum class避免隐式转换,此时要进行转换只能通过static_cast<>显式转换。

        2. 向上转换: 指沿着继承逻辑接口向基类转换。

        3. 向下转换: 虽然允许这么做,但是由于不进行运行时类型检查所以有一定危险,可能意外允许了基类对象调用派生类成员。 (”编译器相信你“)

        4. 将void*指针转换成其他类型。 c风格可太喜欢这玩意了。通常是用来传递函数指针的,但是现在用处少些了因为有新标准替代方案。 但是用<functional>标头会增加运行时开销。

          int(*)(int,int)//一个函数指针的类型
          
      3. dynamic_cast<> 用处只是根据基类指针推断其派生类型,利用了运行时多态所以类型一定要有虚函数。 如果用在其他地方会出错。

      4. const_cast<> 用处只是提供了从常引用/指针修改其指向的非常量的渠道。 毕竟有用常引用作函数形参来同时接受左值实参和右值实参的习惯,按这个语义常引用通常不会接受常量值。 如果指向了一个常量该转换会报错。

      5. reinterpret_cast<> 低级转换,最好别用。

(本来有刻意避开这些内容的但是感觉再避也避不开了)



©

comment 评论区

添加新评论

face表情



  • ©2026 bilibili.com

textsms
内容不能为空
昵称不能为空
email
邮件地址格式错误
web
beach_access
验证码不能为空
keyboard发表评论


star_outline 咱快来抢个沙发吧!




©2026 Aquacolor

Theme Romanticism2.2 by Akashi
Powered by Typecho