浅谈 C++ 面向对象编程

· · 科技·工程

0.日志

可以骗二连吗?这对我真的很重要…… ::::warning[迁移] 因为原专栏出现了一些问题,所以迁移过来的。 :::: ::::success[修订日志] [2024/9/29]{1} 版草稿。\ [2025/1/xx] 修改中……\ [2025/2/12~15] 修改第 {1} 版,架构大改,备份希丰展。第 {2} 版通过审核!\ 更新了内容:

  1. 构造函数(Constructor)是一种特殊的函数,用来在创建类对象时进行特殊的操作来构造这个对象:例如当我们定义了一个链表类,我们就应当先用头指针指向尾指针(这是特殊操作),并且长度初始为 {0}。 那么,我们就需要知道这样的方法:构造函数。\ 它的语法是这样的:
    class List{
         public:
             List();  //构造链表,这里就不实现了。
    };

    (1)构造函数的函数名必须和类同名,并且前面不能有返回类型!\ (2)你可以让编译器提供一个默认的构造函数,你也可以不编写这个函数,但是如果碰到跟底层的指针有关的,建议你还是老老实实地写你的构造函数吧。\ (3)通过直接初始化(List L(10);)或显式类型转换(static_cast<List>(10))来触发构造函数的调用[^5]。除非用 explicit 关键字修饰,否则编译器允许隐式构造(例如 List L = 10;)。若希望禁止隐式转换,应在构造函数前加 explicit

  2. 析构函数(Destructor)在对象生命周期结束时调用(如离开作用域、delete),用于释放资源。继续沿用上面的栗子,当对象的生命周期后,我们删除链表里的每一个元素,此时就需要使用到析构函数。\ 它的语法是这样的:
    class List{
         public:
             List();  //构造链表,这里就不实现了。
             ~List(); //析构链表,这里就不实现了。
    };

    (1)析构函数的函数名是构造函数的函数名加上一个波浪符,当然,它也不能拥有返回类型。\ (2)如果不是跟指针有关的程序,编译器提供的析构函数就可以了,否则你得自己提供。但是,一旦跟指针有关系(除非你用 STL),必须写析构函数!\ (3)析构函数通常不应当显式调用[^6]!

    4.2 再次提升:重载运算符

    重载运算符(Operator Overloading)是一种方便写代码的一个非常好用的方法。\ 首先我们要了解一个东西:元数(Arity),意思是指:某一个运算符可以承载多少个操作对象。\ 当然,还有很多运算符可以重载,见第 7 章。

  1. 析构函数
  2. 拷贝构造函数
  3. 拷贝赋值运算符
  4. 移动构造函数(C++11)
  5. 移动赋值运算符(C++11) ::::info[原因]{open} 因为如果类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符,通常意味着它管理着某些资源(如内存、文件句柄等)。在这种情况下,移动操作通常也需要自定义以避免资源管理问题。 ::::
    4.5.3.2 零法则

    理想情况下,类不应自定义任何拷贝/移动操作或析构函数,而是依赖:

  6. 编译器自动生成的默认实现
  7. 使用智能指针等 RAII 类型管理资源 ::::success[优势]{open} 遵循零法则的类更容易维护,更少出错,且能自动获得正确的拷贝、移动和析构语义。 ::::

    4.6 this 指针

    this 指针是作为隐含参数传递给非静态成员函数的。静态成员函数没有 this 指针。 这样的一个代码片段:

    A.Method();

    若在成员方法 Method 中,使用了 this,则 this 表示的就是 A 的地址。

    4.6.1 箭头法

    如果使用 this,一定要注意:如果你不是使用 4.6.2 的解引用法,那你应该调用 this 的成员方法时,需要使用 -> 运算符,因为它是一个指针

    4.6.2 解引用法

    也可以使用 (*this). 来调用,两者差别不大。

    5. 一般模板

    模板(Template),是 C++ 中重要的一环,它将面向对象编程与泛型编程很好的契合在了一起。

    鉴于篇幅,本章讲的会跳的比较快。

    5.1 函数模板

    函数模板是这样的:

    template<typename T1,...>
    <返回类型> <函数名>(<参数列表>){
    <具体实现>
    }

    在对应类型 Type 的函数模板实例化时,编译器会根据传入的参数类型生成对应的函数代码。\ 举栗子:

    template<typename T>
    T Add(T A,T B){
    return A+B; //注意:这里的 + 运算符必须是 T 类型支持的运算符。(这是一种隐含的约束)
    }
    //对应的 int 会实例化为:
    int Add(int A,int B){
    return A+B;
    }
    //对应的 double 会实例化成:
    double Add(double A,double B){
    return A+B;
    }

    5.2 类模板

    类模板是这样的:

    template<typename T1,...>
    class <类名>{
    <具体的属性与方法>
    };

    举栗子:

    template<typename T>
    class List{...};
    //对应的 int 会实例化成:
    class List<int>{...};
    //对应的 double 会实例化成:
    class List<double>{...};

    如果没懂,可以看看这个:

    vector<int> A; //实例化了一个 int 类型的 vector 类
    vector<double> B; //实例化了一个 double 类型的 vector 类

    是不是很熟悉?事实上,vector 就是一个类模板。

    6. 继承

    继承(Inheritance),同样也是 C++ 中重用代码的一个要点。 ::::info[举栗子]{open} 青蛙是动物,所以青蛙拥有了动物的全部特点。\ 但是动物不都是青蛙,因为青蛙有青蛙其自身的特点。 ::::

    6.1 继承模板

    这里是一个类继承的模板,放在这里后续章节会用到:

    class UserClass:<继承方式><派生的父类>,<继承方式><派生的父类>,...{
    <具体的属性与方法>
    }

    6.2 公有继承

    公有继承是面向对象编程里继承方式的一种,是构建类层次结构的重要手段。在公有继承中,派生类继承基类的成员,基类的公有成员和保护成员访问权限在派生类中保持不变,即公有成员仍可被外界访问,保护成员仍只能在类及其派生类内访问,而基类私有成员不可直接访问。\ 它体现 “是一个”(Is-A) 关系,意味着派生类对象属于基类对象的一种。借助公有继承,能有效复用基类代码,减少重复开发,还可实现多态,通过基类指针或引用调用派生类方法,提升程序的可维护性与扩展性。\ 它的语法结构只需要在模板的继承方式改成第 2 章学过的 public 标识符就可以了。

    6.3 保护继承与私有继承

    私有继承是将基类的公有 / 保护成员变为派生类的私有成员,派生类内部仍可访问,只是外部无法访问。保护继承则介于两者之间,派生类内部(及友元)可以访问基类的公有和保护成员,但外部不可见。

    6.4 虚函数

    6.4.1 虚函数的要点

  8. 可在任何方法上添加 virtual 关键字,例如:virtual void DoIt()
  9. 一旦某个函数在基类中为虚函数,那么在子类将不会为非虚函数
  10. 虚函数会增加对象大小(V-Ptr)、妨碍内联优化,在嵌入式或高性能场景中是劣势。

当我们使用基类的引用或指针调用基类中定义的某个函数时,我们并不知道该函数真正的对象是什么类型(属于哪个类),因为它可能是一个基类的对象,也可能是一个子类的对象。\ 非虚函数和虚函数有一个很重要的区别:

  1. 如果类是为继承设计的,就谨慎标识虚方法。
  2. 在实现一个多层次的类继承关系时,最基本的基类应该只需要虚方法(甚至是接下来要讲的纯虚方法)。

    ::::warning[是否定义成虚函数的要点]{open} 当你发现某个方法虚函数与非虚函数的执行路径可能相同(说人话就是执行结果相同),但是两者的时间差距过大,还是用非虚函数比较好。(毕竟大家都分得清楚,是解释速度快,还是编译速度快) ::::

    6.4.3 对象切片

    ::::error[问题]{open} 当派生类对象被以值传递的方式赋值给基类对象时,会发生对象切片(Object Slicing)。派生类特有的成员数据会被丢弃,只保留基类的部分。这不仅会导致数据丢失,还会使虚函数绑定失效。例如:

    class Base{
     public:
         virtual void Print()const{cout<<"Base\\Numbers";}
    };
    class Derived:public Base{
     public:
         void Print()const override{cout<<"Derived\\Numbers";}
    };
    void Func(Base B){
     B.Print(); // 总是调用Base::print(),即使传递的是Derived对象。
    }
    int main(){
     Derived D;
     Func(D); // 将D以值传递方式传递给Func,发生切片。
    }
    因此,C++中的多态必须通过基类的指针或引用来实现,以避免切片问题。在函数参数中,应使用 Base&Base*,而不是 Base
    #### 6.4.4 虚函数实现原理
    虚函数通过虚函数表(V-Table)实现动态绑定:
  3. 每个包含虚函数的类都有一个虚函数表;
  4. 表中按声明顺序存储虚函数指针;
  5. 在构造函数中设置指向 V-Table 的指针(V-Ptr);
  6. 调用虚函数时通过 V-Ptr 查找实际函数地址;
  7. 虚函数调用比普通函数调用多了指针解引用操作,在性能关键代码中需注意;
  8. 虚函数表会增加每个对象的内存开销(通常增加一个指针大小)。 ::::warning[性能提示]{open} 虚函数调用比普通函数调用:
  9. 多一次指针解引用(访问虚表);
  10. 多一次内存访问(获取函数地址);
  11. 通常无法内联优化。 在性能关键代码中(如高频循环),应谨慎使用虚函数。 ::::

    6.5 抽象的基类与纯虚函数^9

    6.5.1 纯虚函数

    纯虚函数是一种特殊的虚函数。纯虚函数通常只声明而不实现。但请注意,C++ 标准允许为纯虚函数提供定义(在类外),这在某些设计模式(如模板方法)中偶尔会被用到。它的定义如下:

    virtual <类型><名字>(<参数>)=0;

    其中,=0是纯虚函数的定义标识符。 纯虚函数必须在子类重新定义,否则子类也将会是一个抽象类。

    6.5.2 抽象类

    含有至少一个纯虚函数的类称为抽象类,不能实例化。 ::::warning[关于抽象类]{open} 抽象类可以包含纯虚函数(不一定非要包含),但是包含纯虚函数的类必定是抽象类。 ::::

    6.6 改写方法

    当你需要改写派生类派生于父类的一个函数,你应当这么做:

  12. 确保需要改写的方法满足以下条件:
    • 派生类中重写的函数必须与基类被重写函数的函数名、参数列表、返回类型(协变返回类型[^10]除外)完全相同。
    • 基类函数为虚函数,基类中的被重写函数需要用 virtual 关键字声明为虚函数,派生类中重写的函数即使不写 virtual 关键字,也默认是虚函数。
  13. 将改写的方法的 () 后面加上关键字 override,表明这个方法被改写了。

    ::::info[但是]{open} 重写方法的访问权限^11可以不同,但一般建议保持一致,避免混淆。一般不推荐改变访问权限。 ::::

    6.7 多重继承

    多重继承(Multiple Inheritance) 即一个类继承多个父类。但是有几种情况要考虑。

    6.7.1 讨论

  1. 拼接、删除。
  2. 查找、替换子串。
  3. 切片。
  4. 转换到 intfloatdoublechar*
  5. 其他函数(长度、首字母大小写、回文检测)。

    7.2 实践

    不完全实现,此处提供一个基本框架。

    7.2.1 从 0 开始

    先写一个基本框架好了:

    #include<iostream>
    #include<cstring>
    #include<algorithm>
    #include<vector>
    using namespace std;
    class String{
     private:
         char* Data;        // 指向字符数组的指针
         int Len;           // 字符数组的长度(假定不包含\0)
     public:
         String()noexcept;                  // 构造函数
         String(const char* CStringing);        // 由 char*构造函数
         String(const String& Other);        // 拷贝构造
         String(String&& Other)noexcept;    // 移动构造
         ~String();                          // 析构函数
         // 赋值运算符
         String& operator=(const String& Other);
         String& operator=(String&& Other)noexcept;
         bool operator==(const String& Other)const;
         char& operator[](int Index);
         const char& operator[](int Index)const;
         // 功能性函数
         int Find(const String &Sub)const;                         // 返回第一个字符的位置。此处不予实现。
         String Replaced(const String &Sub,const String &Rhs)const;// 返回替换后的字符串,不进行赋值。此处不予实现。
         String Slice(int Left,int Right)const;                    // 返回类似 Python [Start:Stop:Step] 的切片。此处不予实现。
         // 以下函数,由于篇幅关系。此处不予实现。
         void ToUpper();  
         void ToLower();
         const char* ToCString()const noexcept{return Data;}
         // 不实现了,太多重载了。
         String Connect(const String& Other)const;
         String Connect(char Other)const;
         String Connect(const char* Other)const;
         // 辅助方法
         friend int Len(const String &Reference)noexcept{return Reference.Len;}
    };

    7.2.2 请输入文本

    ::::success[完整版不会放在结尾]{open} 如有优化建议、语法错误、需要代码等请私信。 :::: 以下是构造函数(家族)和析构函数的实现。 ::::info[需要自取,自行优化]

    String::String()noexcept:Data(new char[1]{'\0'}),Len(0){}
    String::String(const char* CString){
     if(!CString){
         Data=new char[1]{'\0'};
         Len=0;
     }
     else{
         Len=strlen(CString);
         Data=new char[Len+1];
         std::copy(CString, CString+Len+1, Data);
     }
    }
    String::String(const String& Other):Len(Other.Len){
     Data=new char[Len+1];
     std::copy(Other.Data, Other.Data+Len+1, Data);
    }
    String::String(String&& Other)noexcept:Data(Other.Data), Len(Other.Len){
     Other.Data=nullptr;
     Other.Len=0;
    }
    String::~String(){
     delete[] Data;
    }

    :::: 接下来实现重载运算符的 {6} 个方法。 ::::info[需要自取,自行优化]

    String& String::operator=(const String& Other){
     if(this!=&Other){
         char* new_data=new char[Other.Len+1];
         std::copy(Other.Data, Other.Data+Other.Len+1, new_data);
         delete[] Data;
         Data=new_data;
         Len=Other.Len;
     }
     return *this;
    }
    String& String::operator=(String&& Other)noexcept{
     if(this!=&Other){
         delete[] Data;
         Data=Other.Data;
         Len=Other.Len;
         Other.Data=nullptr;
         Other.Len=0;
     }
     return *this;
    }
    bool String::operator==(const String& Other)const{
     return strcmp(Data, Other.Data)==0;
    }
    char& String::operator[](int Index){
     if(Index<0||Index>=Len)throw std::out_of_range("Index out of range");
     return Data[Index];
    }
    const char& String::operator[](int Index)const{
     if(Index<0||Index>=Len)throw std::out_of_range("Index out of range");
     return Data[Index];
    }

    ::::

    8. 附录——拾遗珠玑

    8.1 左值 or 右值