浅谈 C++ 面向对象编程

· · 科技·工程

1. 简介

面向对象编程(OOP)是一种编程范式,它将数据和操作封装成对象,通过类来创建对象,用对象模拟现实世界中的实体。它具有封装性,隐藏内部细节;继承性,实现代码复用;多态性,同一操作针对不同对象有不同表现。这让程序结构更清晰、易维护和扩展。\ 当然,这篇文章不一定全面,类似“模板套模板与模板类套模板类”这类很多东西都没有办法写在这里,因为涉及到的东西太多了,还请各位谅解。

2. 从基本开始——类

2.1 基本类模板

这是一个基本类的模板,放在这里,后续会用到。

class <类名>
{
    private:       //关键字 private 是一个访问权限标识符;
        <属性与方法>
    public:        //关键字 public 是一个访问权限标识符;
        <属性与方法>
    protected:     //关键字 protected 是一个访问权限标识符;
        <属性与方法>
};                 //这是类定义的结束,必须有这个分号。

2.2 三种访问权限标识符

在类中定义的函数、变量,我们应当称之为:方法(Methods)和属性(Attribute),下文也是如此。

2.2.1 公开访问

这个标识符是 public,公有成员在类内外^1均可访问。用于定义类对外接口,像类的公共方法和数据成员,方便外部代码与之交互,实现对象功能调用。

2.2.2 私有访问

这个标识符是 private,私有成员仅类内^2可访问。用于隐藏类的实现细节,防止外部随意修改,保障数据安全与一致性,由类内方法间接操作。

2.2.3 保护访问

这个标识符是 protected,保护成员在类及子类内可访问。为类的继承提供便利,允许子类访问父类部分成员^3,实现代码复用与扩展同时保护关键成员。详见第 6 章。

2.3 使用对象

当你需要定义一个变量,语法都是一样的:

<类型名> <变量名>;

3. 使用方法和属性

定义方法和属性和一般程序定义函数与变量的方式一样,不过需要注意下面两种定义方法的方式:

方式 1:直接在类内提供方法的实现。

class UserClass
{
public:
void Debug(int Time)
{
<具体实现>
}
};

方式 2:在类内提供方法原型,在类外提供实现。

class UserClass
{
public:
void Debug(int Time);
};
UserClass::void Debug(int Time)
{
<具体实现>
}

这两种方式都可以。当然,调用时,和 struct 使用一样的语法:

<对象>.<属性/方法>(<方法的参数>)

4. 渐渐提升——特殊方法

4.1 两种特殊的成员函数

这里会介绍 {2} 种特殊的成员函数^4,但实际上有 {6} 中特殊的成员函数,由于涉及的内容太多,就不介绍了。

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

    (1)构造函数的函数名必须和类同名,并且前面不能有返回类型!\ (2)你可以让编译器提供一个默认的构造函数,你也可以不编写这个函数,但是如果碰到跟底层的指针有关的,建议你还是老老实实地写你的构造函数吧。\ (3)构造函数可以显式调用^5,方式是这样的:List Event();,当然,构造函数也可以拥有参数,此时想要构造就必须显式构造

  2. 析构函数(Deconstructor)当程序结束后,这种函数进行垃圾回收或其他特殊操作,一般用于清理垃圾、整合内存。继续沿用上面的例子,当你在程序结束后,我们消除链表里的每一个元素,此时就需要使用到析构函数。\ 它的语法是这样的:
    class List
    {
    public:
    List();  //构造链表,这里就不实现了。
    ~List(); //析构链表,这里就不实现了。
    };

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

    4.2 再次提升:重载运算符

    首先我们要了解一个东西:运算符承受数(Operator Acceptance),意思是指:某一个运算符可以承载多少个操作数。\ 当然,还有很多运算符可以重载,这里就不一一详述了。

    • 几种一元运算符:+-!*&--++
    • 以下是可以重载的二元运算符及其一般作用:
    • + 运算符:用于对两个对象进行算术相加。
    • - 运算符:用于对两个对象进行算术相减。
    • * 运算符:用于对两个对象进行算术相乘。
    • / 运算符:用于对两个对象进行算术相除。
    • % 运算符:用于对两个对象进行算术相求余。
    • >><< 运算符:流运算符或位左右移运算符。
    • &|^ 运算符:用于位的运算符,可以用来替代逻辑运算符。
    • 所有的简写运算符都可以重载。
    • 下标运算符 [] 它拥有两种形式:读和写。
  3. 使用这个运算符应当只填写一个参数。(它的运算符承受数是 {1}
  4. 应当提供两个函数形式:一个返回引用(用于写),另一个返回常量(用于读)。
    • 唯一一种多元运算符 (),即函数调用运算符,它的最大承受数是 {256(2^{8})}

      重载运算符的格式应当是这样的:

      <返回类型> operator <重载的运算符>(<参数列表>)
      {
      <具体实现>
      }
    • 如果你需要重载的运算符你用成员方法实现,那么参数列表中就会少一个参数。因为第一个操作数被隐含成 *this,所以不能使用,但是在代码中应当使*this 来调用第一个操作数
    • 但是如果你使用友元方式(见4.3)重载运算符的时候,参数个数就是操作符承受数

      还有几点要在这里强调:

    • 不要乱重载运算符,比如 * 号重载成交换两个对象,尽管语法上没错,但是看起来就很怪异,应当使用其他函数来替代,例如:Swap()
    • 有些运算符千万不要重载 / 不建议重载
    • 两种二元逻辑布尔运算符,即 &&|| 这两种,重载这两个运算符会失去短路求和功能。
    • 不要重载 , 运算符,它叫序列运算符(Sequence Operator),它只适用于保证求值顺序从左至右。几乎没有什么正当理由需要重载它。
    • 不能重载 . 运算符,也就是成员运算符。
    • 不能重载 .* 运算符,也就是成员指针运算符。
    • 不能重载 :: 运算符,域访问重载后会失去语义。
    • 关于指针的两种运算符 newdelete 说明一下:除非你很了解底层指针的工作原理,否则不要重载它们,因为这两种运算符包含 {5} 种基本形式,又有 {3} 种数组形式,可以重载的又有 {4}形式,并且重载过程很麻烦;而且可能与编译器的 newdelete 不兼容。
    • 不建议重载 -> 运算符,重载这个运算符你需要重载上面两种运算符。
    • 部分运算符不能用来使用友元重载,只能作为类成员重载。
    • 重载的运算符不能也不会改变它的优先级

      4.3 友元函数

      首先强调:友元函数不是成员方法!它不能通过成员运算符(.)来调用。\ 声明友元函数的格式如下:

      friend <返回类型> <函数名>(<参数列表>)
      {
      <具体实现>
      }

      但是有几点需要注意:

    • 友元函数如果要声明为内联函数,应当使用 friend inline 或是 inline friend,但是编译器解析这两种函数定义时会有不同的步骤。
    • 前面提到的 {6}特殊的成员函数不可以成为友元函数,当然还包括一些运算符重载(例如 =)也不可以。
    • 友元函数可以访问类的私有属性与方法!

      4.4 转换函数

      如果我们需要将自定义的类转换成另一种类,就需要用到转换函数。转换函数一般分为两种:转换成其他类或者是接受了其它类。

      4.4.1 转换成其他类

      这是模板:

      operator <类型名>()
      {
      <具体实现>
      }

      注意:<类型名>是函数名,正是由于此处指明了输出类型,所以该函数省略了返回值。编译器会根据函数名决定返回值类型。

      4.4.2 接受其他类

      这是模板:

      <类名>(<类型名><替代名>)
      {
      <具体实现>
      }

      看上去很像构造函数的函数名,实际上,这种转换就是构造函数的变体。

      4.4.3 提示

      所有的转换函数都可以在后面加上 explicit 关键字,这个关键字要求转换必须显式

      4.5 拷贝与移动

      4.5.1 拷贝

      拷贝,一般是由一个对象复制给另一个对象(分为深拷贝浅拷贝)一般的,如果生成默认构造函数,且在没有禁用该函数的情况下,会生成一个拷贝函数组:拷贝赋值(复制赋值读起来有点怪异,所以用拷贝)与拷贝构造。\ 拷贝构造,一般是一个临时值的复制时才会用到拷贝构造。同理,拷贝赋值是在复制对象并且运用了赋值运算符时才会触发。

      4.5.2 移动

      左右值概念见 7.1.\ 移动是将对象销毁前,将资源标记为另一个对象的简单、省空间的方法。生成条件同理。\ 所有的移动函数的参数都应当包含右值引用。因为有些左值不可以移动!

      5. 一般模板

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

      5.1 引入模板

      先来看一个比较两个整型数中较大数的版本:

      int max(int a,int b){return a>b?a:b}

      但是如果需要再写一个比较两个浮点数的版本?你是不是想到了再写一个:

      double max(double a,double b){return a>b?a:b}

      如果还要编写超长整型呢?你应该会再写一个[^7]:

      using ll=long long;       //在 C++11 中的新标准,允许这样使用类型别名
      ll max(ll a,ll b){return a>b?a:b}

      如果有更多类型需要比较,你的函数可能会写的越来越多,所以,为了方便,添加了“模板”这一技术。

      5.2 一般函数模板语法

      一般模板的语法是这样的:

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

      当然,上述代码中的 typename 也可以替换成 class。不过建议使用 typename 来向后兼容 C++ 版本。注意一点:一个模板定义头只能用于一个函数 / 类。\ 那么上面的 max 函数就可以编写成:

      template<typename Type>
      Type max(Type a,Type b){return a>b?a:b}

      尽管以后有高精度数,也只需要编写一个 operator > 即可。

      5.3 可变长参数列表

      好消息:C++11 有了参数安全的变长参数列表函数,此时就可以用像传统的 printf 函数一样,拥有可变参数模板。\ 这个是变长参数函数的模板:

      template<typename <类型替代名>>
      <返回类型><函数名>(<第一对参数><参数包>...);
      template<typename <类型替代名>>
      <返回类型><函数名>(<一对参数>);

      举个栗子:编写一个可以容纳很多参数的 max 函数。\ 首先编写 {2} 个模板函数^8:

      template<typename Tp>
      Tp max(Tp n){return n};
      template<typename T1,typename ...Targs>
      Tp max(T1 n,Targs... lst){return n>max(lst...)?n:max(lst...)}

      我们只看第二个模板函数:\ (1)模板和函数原型中使用了 {2} 个省略号,这是参数包符号,表示回传很多参数。\ (2)我们使用递归:每次都与后面的最大数去比(尽管效率很低下,因为不是记忆化递归)。\ (3)注意:lst... 是表示整个参数包。

      5.4 一般模板类语法

      考虑到有些程序需要设计一些不同类型但是实现基本一样的类,于是就有了“模板类”一说。\ 模板在这,自取:

      template<typename 模板类型名,typename 模板类型名,...>
      class <类名>
      {
      <类的成员>
      };

      注意,同函数模板一样,每一个模板定义头都只能对应一个函数 / 类。\ 注意,这个概念和以下概念截然不同:

      class UserClass
      {
      template<typename <类型名>>
      <类型名><方法名>();
      };

      这个叫类内模板方法,但是一般用不到,用的更多的是上面的模板类

      5.5 模板实例化

      模板实例化是 C++ 模板编程扯远了,扯到泛型编程了中的重要的一个部分,它让模板(函数模板或类模板)生成具体的函数或类。下面分别介绍函数模板和类模板的实例化。

      5.5.1 函数模板实例化

      函数模板定义了一个通用的函数,通过实例化可以生成针对特定类型的具体函数。\ 具体运用方法:<已经定义的模板函数><类型名>(<其他参数>)。注意:类型名左右两边的 <> 不可以漏掉。\ 举个栗子:

      return pow<int>(2,3);

      此时程序返回的结果应该是整型数字 {8}

      5.5.2 类模板实例化

      有了函数模板实例化,就自然拥有类模板实例化,模板如下:<已经定义的模板类><类型名> <对象名>。同样,类型名左右两边的尖括号不能省略。

      6. 继承

      举一个栗子:青蛙是动物,所以青蛙拥有了动物的全部特点。但是动物不是青蛙,因为青蛙有青蛙其自身的特点。

      6.1 继承模板

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

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

      6.2 公有继承

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

      6.3 保护继承与私有继承

      私有继承将父类的所有成员都隐藏起来,只允许子类通过继承来的方法进行间接访问;保护继承则介于两者之间,允许子类通过派生类或友元函数访问父类的公有和保护成员。但是由于几乎用不到,这里就不详述了。

      6.4 虚函数

  5. 可在任何方法上添加 virtual 关键字,例如:virtual void DoIt()
  6. 一旦某个函数在基类中为虚函数,那么在子类将不会为非虚函数
  7. 声明虚方法除了使程序慢一点点(查找虚函数表)以外,没有任何缺点。

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