技术: C++面向对象部分

C++ 面向对象部分总结.

几个月前我总结了一下c-plus-plus中涉及到的核心内容, 包括 STL, Boost. 但是并没有 “详细” 去说内容, 现在有时间了, 详细总结一下 面向对象基础.

引子

本篇主要讲 三大特性, 我把它分成了以下7个部分:

  • 封装部分
  • 构造析构部分
  • 静态成员部分
  • 对象模型部分(重点)
  • 友元部分
  • 运算符重载部分(重点)
  • 多态部分(重点)
  • 纯虚函数抽象类部分(重点)

正文

三大特性: 封装, 继承, 多态.

封装

封装的两层含义:

  • 把属性和方法进行封装(整合)
  • 对属性和方法进行访问控制(对外控制)

构造和析构函数

  • 构造函数和析构函数的作用

    构造函数处理对象的初始化, 构造函数是一种特殊的成员函数, 与其他成员函数不同, 不需要用户来调用它, 而是在建立对象时自动执行.析构函数销毁的时候, 自动调用, 释放对象占用的资源.(不用显示调用虽好, 但是cpp的设计, 有时候你不知道编译器偷偷在后面干了什么事儿)

  • 构造器的分类

    无参, 有参, 拷贝构造(包括后来添加的移动构造之类的)

  • 拷贝构造函数调用时机
    (拿一个已经存在的对象去初始化另外一个对象; 以及函数调用&返回值采用值传递的情况)
    可能会产生中间变量, 例如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    Location g()
    {
    Location A(1, 2);
    return A;
    }
    /*调用*/
    /*对象初始化操作 和 =等号操作 是两个不同的概念
    匿名对象的去和留, 关键看返回时如何接, 但是匿名对象一定会产生 */
    void mainobjplay()
    {
    //若返回的匿名对象, 赋值给另外一个同类型的对象, 那么匿名对象会被析构
    Location B;
    B = g(); //用匿名对象 赋值 给 B 对象, 然后匿名对象析构

    //若返回的匿名对象来初始化另外一个同类型的对象, 那么匿名对象会直接转成新的对象
    //意思是说匿名对象被扶正了, 不会被析构(小三上位了)
    Location B = g();
    }
  • 默认的构造器

    默认无参和默认拷贝构造器, 以及构造器的重载

  • 构造函数生成规则

    一旦提供了构造函数, 编译器不再提供默认构造器; 拷贝构造只有在使用到了才会默认生成.

  • 深拷贝和浅拷贝

    默认拷贝最怕有指针成员.解决方法: 手动提供拷贝构造函数, operator=()重载(这部分可以自己写一个String类就明白了).

  • 初始化列表

    • 类成员是另外对象(该类没有提供无参构造器)
    • 类成员中含有一个 const 对象
    • 类成员存在引用

    补充详细说明:

    如果我们有一个类成员, 它本身是一个类或者是一个结构, 而且这个成员它只有一个带参数的构造函数, 没有默认构造函数. 这时要对这个类成员进行初始化, 就必须调用这个类成员的带参数的构造函数, 如果没有初始化列表, 那么他将无法完成第一步, 就会报错.

  • 初始化和赋值的区别

    初始化: 被初始化的对象正在创建
    赋值: 被赋值的对象已经存在
    (在构造函数体中是对他们的赋值, 而不是初始化, 所以const和引用必须使用初始化列表)

  • 构造和析构顺序

    • 成员变量的初始化顺序与声明的顺序相关, 与在初始化列表中的顺序无关
    • 初始化列表先于构造函数的函数体执行
    • 当类中有成员变量是其它类的对象时, 首先调用成员变量的构造函数, 调用顺序与声明顺序相同, 之后调用自身类的构造函数
    • 析构函数的调用顺序与对应的构造函数调用顺序相反
  • 构造器委托

    构造函数中调用构造函数, 是一个蹩脚的行为(不但达不到目的, 还会产生临时对象)

  • 编译器对于new的处理

    在执行 new 运算时, 如果内存量不足, 无法开辟所需的内存空间, 目前大多数 C++编译系统都使 new 返回一个 0 指针值. 只要检测返回值是否为 0, 就可判断分配内存是否成功; ANSI C++标准提出, 在执行 new 出现故障时, 就“抛出”一个“异常”, 用户可根据异常进行有关处理. 但 C++标准仍然允许在出现 new 故障时返回 0 指针值. 当前, 不同的编译系统对 new 故障的处理方法是不同的.(注意new关键字引起系列动作)

  • malloc和new最关键的区别

    new关键字会引起系列动作, 远非一个malloc库函数可比. 简要的说, 区别在于会不会主动调用构造器, 编辑虚表指针等.(当然还有更多, 值得我们单独讨论; 区分以下 newoperator new() 就对了)

静态成员部分

静态数据成员以及静态成员函数

  • 所有对象共享, 不属于对象成员, 而是类属性.
  • 放在类外初始化(最好放在cpp文件中)
  • 非静态能访问静态成分, 但是静态不能访问非静态成分
  • 静态成员函数提供不依赖于类数据结构(对象)的共同操作, 它没有 this 指针
  • 静态成员函数有两种调用方式, 但最终都会转换成通过类名限定进行调用

(静态, 说白了是一种共享机制)

C++对象模型部分

这部分主要就是说c++比c多了哪些面向对象的特性, 以及底层是怎么实现的.
大致分为两个部分: (但两部分都要涉及编译器对于相关feature的支持)

  • 语言中直接支持面向对象程序设计的部分
    主要涉及如构造函数,析构函数,虚函数,继承(单继承,多继承,虚继承),多态等等
  • 底层实现(例如虚函数, 多态的实现需要借助虚表指针)

实现部分详细说: (参考一下 “inside the cpp obj model” 这本书)

  • 直接绑定
    在 c 语言中, “数据” 和“处理数据的操作(函数)” 是分开来声明的, 也就是说, 语言本身并没有支持“数据和函数” 之间的关联性; 在 c++中, 通过抽象数据类型(abstract datatype, ADT), 在类中定义数据和函数, 来实现数据和函数直接的绑定.

  • 成分分类
    两种成员数据: static, nonstatic
    三种成员函数: static, nonstatic, virtual

  • 类, 对象以及其关系的支持
    c++编译器是如何区分是哪个具体的对象调用这个方法呢?—this指针

  • 编译器对属性和方法的处理机制

    • C++类对象中的成员变量和成员函数是分开存储的
      • 普通成员变量: 存储于对象中(与 struct 变量有相同的内存布局和字节对齐方式)
      • 静态成员变量: 存储于全局数据区中
      • 成员函数: 存储于代码段中
    • C++编译器对普通成员函数的内部处理(编译时增加了一个this参数)
      隐式包含一个指向当前对象的 this 指针, 类的成员函数可通过 const 修饰, 这个const其实就是修饰的this指针.
  • 补充this指针

    • 把全局函数转化成成员函数, 通过 this 指针隐藏左操作数
      Test add(Test &t1, Test &t2) ===》 Test add( Test &t2)
    • 把成员函数转换成全局函数, 多了一个参数
      void printAB() ===》 void printAB(Test *pthis)
    • 许多设计模式里面也会用到传递this指针, 比如说状态模式.
    • 函数返回元素和返回引用
      1
      2
      3
      4
      5
      6
      Test& add(Test &t2) //*this //函数返回引用
      {
      this->a = this->a + t2.getA();
      this->b = this->b + t2.getB();
      return *this; //*操作让 this 指针回到元素状态
      }

友元部分

访问限制符public或者private或者protected, 根本无法限制friend修饰的友元函数或者友元类.

  • 外部(全局)友元函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Point
    {
    private:
    double X;
    double Y;
    friend double Distance(Point &a, Point &b);
    };
    double Distance(Point &a, Point &b){
    double dx = a.X - b.X;
    double dy = a.Y - b.Y;
    return sqrt(dx*dx + dy*dy);
    }
  • 友元类
    (若 B 类是 A 类的友员类, 则 B 类的所有成员函数都是 A 类的友员函数–可以直接通过A的外部对象访问私有数据的)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class A
    {
    friend class B; //B是A的友元
    private:
    int x;
    };
    class B
    {
    private:
    A aObj;
    public:
    void Set(int i) {
    aObj.x = i;
    }
    }

    (可以看到友元类中无隐私)

友元类, 一般用做类之间传递消息的辅助类(迪米特法则, 不和陌生人讲话).

运算符重载部分(重点)

(这一部分很强大, 但是不是很好用, 其他有一些编程语言如Java直接隐藏了该功能)

好在常用的重载运算符符号也不是很多: 自增自减, 算数运算符, <<和>>, 赋值, [], ()

  • 简介
    现在貌似用的最多的就是仿函数(或者说函数对象)领域用到的 operator()()”一名多用”, 同样的运算符赋予不同的, 新的含义.
    举例:
    用户自定义类型编译器无法让变量相加, 此时就要对Complex这个类进行一下重载.

    1
    Complex c3 = c1 + c2;

    (编译器在编译时进行改名, 所以支持重载)

  • 不能重载的运算符

    . 和 .* 和 ?: 和 :: 和 sizeof

  • 重载方式
    成员函数和友元函数(主要是参数个数的问题):

    重载运算符函数名: operator+(参数表)

    • 隐式调用形式: obj1+obj2
    • 显式调用形式:
      • obj1.operator+(OBJ obj2)—成员函数
      • operator+(OBJ obj1,OBJ obj2)—友元函数

    一般情况下建议`一元运算符使用成员函数, 二元运算符使用外部友元函数, 多个参数的时候尽量使用外部友元函数. 具体规则:

    • 所有一元运算符采用成员函数
    • 一般二元运算符, 例如: , +, *, / 应该重载为外部友元函数, 特殊二元如下:
      • = ( ) [ ] -> —– 必须重载为成员函数(为啥?因为需要this指针)
      • += -= /= *= ^= &= != %= >>= <<= —- 应该重载为成员函数
    • << `>> —– 必须是友元函数

更加一般性的规则: (和本类对象有关, this有关的, 都用成员函数的方式; 第一个参数不是本类指针或者引用,对象的, 都要外部函数的方式)

* 运算符的操作需要修改类对象的状态则使用成员函数(例如需要做左值操作数的运算符(如=,+=,++)), 并且函数返回值充当左值, 一般返回一个引用
* 运算时, 有数和对象的混合运算时, 必须使用外部友元函数
* 二元运算符中, 第一个操作数为非对象或者非本类对象时, 必须使用外部友元函数, 如输入输出运算符 `<<` 和 `>>` 

根据开发的习惯:

* (), =, [], ->全部重载为成员函数
* >>和<<重载为外部友元函数

下面介绍一下常用的重载

  • 前缀和后缀

    • 具体的例子
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      class Point  
      {
      private:
      int x;
      public:
      constPoint operator++();
      Point operator++(int x);

      friend const Point operator--(Point& p);
      friend Point operator--(Point& p, int x);
      };

    说明:

    • a++
      函数返回: temp(临时变量)
      函数返回是否是const类型: 返回是一个拷贝后的临时变量, 不能出现在等号的左边(临时变量不能做左值), 函数的结果只能做右值, 则要返回一个const类型的值

    • ++a
      函数返回: *this;
      函数返回是否是const类型: 返回原状态的本身, 返回值可以做左值, 即函数的结果可以做左值, 则要返回一个非const类型的值

    • 其他补充: 参数-返回值的const问题

      • 如果返回值可能出现在=号左边, 则只能作为左值, 返回非const引用
      • 如果返回值只能出现在=号右边, 则只需作为右值, 返回const型引用或者const型值
      • 如果返回值既可能出现在=号左边或者右边, 则其返回值须作为左值, 返回非const引用

其他运算符重载 : [] 运算符, ()运算符, >>或者<<

  • [] 运算符
    重载下标运算符[ ]的目的 :
    • 对象[x] 类似于 数组名[x],更加符合习惯
    • 可以对下标越界作出判断
      语法 :
    • 重载方式: 只能使用成员函数重载
    • 函数名: operator
    • 参数表: 一个参数, 有且仅有一个参数, 该参数设定了下标值, 通常为整型, 但是也可以为字符串(看成下标)
    • 函数调用:
      • 显式调用:Obj[arg]-对象[下标]
      • 隐式调用:obj.operator
        *返回类型:
      • 返回函数引用 或者 返回成员的实际类型(由程序员根据函数体定义)
      • 因为返回值可以做左值和右值, 应该不使用返回值为const类型
  • () 运算符

    • 重载运算符( )的目的:

      • 对象( ) 类似于 函数名(x),更加符合习惯
      • 作为函数对象functor, 用于回调
    • 语法:

      • 重载方式: 只能使用成员函数重载(重载后还可以继续重载)
      • 函数名: operator( )(参数表)
      • 参数表: 参数随意,具体根据实际情况而定。
      • 函数调用:
        • 隐式调用: Obj(x)
        • 显式调用: obj.operator( )(x)
      • 返回类型:
        • 返回成员的实际类型随意(具体由程序员根据函数体定义)
        • 因为返回值只能做右值,只读,应该使用返回值为const类型
  • >> 或者 << 运算符

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Point  
    {
    private:
    int x;
    public:
    Point(int x1){ x=x1; }

    //可以看到全部使用引用
    friend ostream& operator<<(ostream& cout,const Point& p);
    friend istream& operator>>(istream& cin,Point& p);
    };
    • 语法:

      • 重载方式: 只能使用友元函数重载 且 使用3个引用&(2个参数返回值);
      • 函数名:
        • 输出流: operator<<(参数表)
        • 输入流: operator>>(参数表)
      • 参数表: 固定(容易出错啊), 两个参数均用引用&
      • 输出流: 必须是两个参数(对输出流ostream& 和 输出对象)
        第一个操作数传入cout, 定义在文件iostream中, 是标准类类型ostream的对象的引用.
        如: ostream& cout, const Point& p
      • 输入流: 必须是两个参数(对输入流ostream& 和 输入对象)
        第一个操作数是cin,定义在文件iostream,实际上是标准类类型istream的对象的引用
        如: instream& cin, const Point& p
      • 函数调用:
        • 输出流:
          • 显式调用 cout<<对象
          • 隐式调用 operator<<(cout, 对象)
        • 输入流:
          • 显式调用 cin>>对象
          • 隐式调用 operator>>(cin, 对象)
      • 返回类型: 返回类型固定 或者 使用返回函数引用(推荐,可以实现链式调用)

        • 输出流: 返回ostream&
          • ostream& operator<<(ostream& cout, const Point& p)
        • 输入流: 返回istream&
          • istream& operator>>(istream& cin, Point& p)

        为什么输入输出操作符的重载必须使用友元函数?
        因为成员函数要求是有对象调用, 则第一个参数必须是本类的对象的引用, 但是 <<>> 第一个参数是流的对象引用. 故不能使用成员函数.

  • =运算符

    • 大概形式

      1
      2
      3
      4
      5
       class Point
      {
      public:
      Point& operator=(const Point&);
      }
    • 一般写法是:

      • 先释放旧内存, 然后分配新内存
      • 实现内容的复制
      • 返回一个引用(实现从右到左的链式调用) *this
        案例是:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      Name& operator=(Name &obj1)
      {
      //1 先释放obj3旧的内存
      if (this->m_p != NULL)
      {
      delete[] m_p;
      m_len = 0;
      }

      //2 根据obj1分配内存大小
      this->m_len = obj1.m_len;
      this->m_p = new char [m_len+1];

      //3把obj1赋值给obj3
      strcpy(m_p, obj1.m_p);
      return *this;
      }
  • 不要重载 && 和 || 以及 , 运算符
    这一条, 具体可以参考一下《effective c++》

    • 主要原因:
      &&和||内置实现操作数的短路规则(即运算顺序), 因为cpp开发者并没有保证函数调用时, 参数校验的顺序, 而&&, ||, 逗号运算符一定是从左到右实行的, 而一旦调用了函数, 实际上两个参数都会校验, 就和原来的短路校验相违背了.
  • 重载案例
    MyString 和 智能指针的(*, ->)运算操作符都是很好的案例.
    (面试喜欢考察这个, 后面专门写)

继承和派生部分

  • 简介

    • 子类拥有父类的所有成员变量和成员函数(除了构造和析构之外的成员方法, (并且这些成员的访问属性, 在派生过程中是可以调整的, 通过不同的继承方式)
    • 子类可以拥有父类没有的方法和属性
    • 子类就是一种特殊的父类
    • 子类对象可以当作父类对象使用
  • 权限问题
    C++中的继承方式(public, private, protected)会影响子类对父类属性(从父类继承而来的属性)的访问(包括类和类外).

    • 继承中的访问控制
      从父类继承的成分, 在继承到子类的时候, 是可以通过不同的继承方式修改其访问权限的, 具体如下:
    • public 继承: 父类成员在子类中保持原有访问级别
    • private 继承: 父类成员在子类中变为 private 成员
    • protected 继承:
      • 父类中 public 成员会变成 protected
      • 父类中 protected 成员仍然为 protected
      • 父类中 private 成员仍然为 private

    private 成员在子类中依然存在, 但是却无法访问到. 不论种方式继承基类, 派生类都不能直接使用基类的私有成员.
    总结就是: 派生类对基类成员的访问由继承方式和成员性质共同决定.

  • protected问题
    设置protected权限在没有继承的时候, 体现不出来其优良的设计; 但是结合继承方式的后, 就发现protected访问权限是专门针对子类设计的.
    总结就是: 访问控制上, private和public分别针对了类内和类外, 而protected是专门用来针对于子类的访问的(类内类外包括父类内外和子类内外).

  • 兼容性规则
    多态的基础之一, 限定范围是: 公有继承.

    具体如下:

    • 子类对象可以当作父类对象使用
    • 子类对象可以直接赋值给父类对象
    • 子类对象可以直接初始化父类对象
    • 父类指针可以直接指向子类对象
    • 父类引用可以直接引用子类对象

    派生类对象就可以作为基类的对象使用, 但是只能使用从基类继承的成员.

  • 继承中的构造和析构

    • 在子类对象构造时, 需要调用父类构造函数对其继承得来的成员进行初始化
    • 在子类对象析构时, 需要调用父类析构函数对其继承得来的成员进行清理

    创建派生类对象时, 先调用基类构造函数初始化派生类中的基类成员, 调用析构函数的次序和调用构造函数的次序相反, 具体规则如下:

    • 子类对象在创建时会首先调用父类的构造函数
    • 父类构造函数执行结束后, 执行子类的构造函数
    • 当父类的构造函数有参数时, 需要在子类的初始化列表中显示调用
    • 析构函数调用的先后顺序与构造函数相反

    继承和组合混搭的时候

    • 先构造父类, 再构造成员变量, 最后构造自己
    • 先析构自己, 在析构成员变量, 最后析构父类
  • 继承中同名变量
    子类和父类中存在同名变量的问题.
    • 当子类成员变量与父类成员变量同名时, 子类依然从父类继承同名成员(但是父类成员默认被屏蔽shadow)
    • 在子类中通过作用域分辨符::进行同名成员区分(在派生类中使用基类的同名成员显式地使用类名限定符), 例如 derive.Base::member;
    • 同名成员存储在内存中的不同位置
  • 继承中static
    如果是基类中定义的static变量, 那么将被所有的派生子类共享; 在派生中的访问规则由父类访问控制和继承方式共同决定.
    派生类中访问静态成员, 用以下形式显式说明:

    • 类名::成员
    • 对象名.成员

    怎么初始化呢?
    谁定义, 谁自己显示初始化.
    (父类自己定义的, 父类自己在类外显示初始化)

多继承(虚拟继承)

这又是个强大而又危险的特性
  • 简介
    大致形式:

    1
    2
    3
    4
    class 派生类名 : 访问控制 基类名 1 , 访问控制 基类名 2 , ... , 访问控制 基类名 n 
    {
    //...
    }

    多继承极大的发挥了重用的特性, 但是也引入了不小的复杂性.

  • 派生类构造和访问

    • 初始化列表调用基类构造函数初始化数据成员
    • 初始化顺序和定义派生的顺序有关, 和初始化列表顺序无关.
    • 一个派生类对象拥有多个直接或间接基类的成员, 不同名成员访问不会出现二义性
      (如果不同的基类有同名成员, 派生类对象访问时应该加以识别)
  • 二义性问题
    如果一个派生类从多个基类派生, 而这些基类又有一个共同的基类, 则在对该基类中声明的变量进行访问时, 可能产生二义性(因为多个直接基类都继承了间接基类的某些特性).具体的说(从子类往上看), 在构造末端子类对象的时候, 间接基类的构造器会被其直接基类调用多次, 导致子类对象会存多个间接基类中的成员.
    (这个时候, 你要访问末端子类中的某些成分可能要指定是哪个直接基类从间接基类得到的)

    举个列子:
    
    a---> b1, b2--->c
    那么c的对象, 如果要访问a的成员, 直接指定 `c.a::成员` 是不行的, 必须指定直接基类, 例如 `c.b1::成员` , `c.b2::成员`.
    
    • 总结一下:
      • 如果一个派生类从多个基类派生, 而这些基类又有一个共同的基类, 则在对该基类中声明的名字进行访问时, 可能产生二义性
      • 如果在多条继承路径上有一个公共的基类, 那么在继承路径的某处汇合点, 这个公共基类就会在派生类的对象中产生多个基类子对象 (可以看到a对象的构造器被调用了多次)
  • 虚继承
    虚继承就是解决存在公共基类时, 访问的成员的二义性问题的.
    指定公共基类的继承方式是 虚拟继承, 使这个公共基类在派生类中只产生一个子对象, 使这个公共基类成为虚基类.虚继承声明使用关键字 virtual.(之后可以直接使用c.a::成员进行访问)

    c.b1::成员 或者 c.b2::成员在末端子类对象c中实际上保留的是一个a对象的指针, 只存在一份公共基类对象, 通过公共基类的构造器被调用的次数就可以知道了.

多态部分(重点)

多态的表现和实现原理.

多态: 同样的调用一句有多种不同的表现形态(具体还是要运行时的具体指向).(多态引入的灵活变化, 也是多种设计模式的基础; 模块间要松散, 模块内要内聚)

virtual

父类的方法, 如果默认没有virtual方法支持, 那么子类中原型的方法就会隐藏父类的方法(或者重定义父类方法). 父类中被重写的函数依然会继承给子类, 默认情况下子类中重写的函数将隐藏父类中的函数. 通过作用域分辨符 ::可以访问到父类中被隐藏的函数, 如下:

1
2
3
4
5
6
7
Child child;
Parent *p = NULL;
p = &child;
p.print(); //parent metdho

child.print(); //child method
child.Parent::print();

说明:

  • 在编译此函数的时, 编译器不可能知道指针 p 究竟指向了什么, 但编译器也没有理由报错, 于是, 编译器认为最安全的做法是编译到父类的 print 函数, 因为父类和子类肯定都有相同的 print 函数. (静态联编) (重载就是采用的这种方式)
  • virtual方法的出现引入了虚表, 多了运行时判别父类指针具体指向或者引用的过程: 运行时如果判别指针指向的子类对象, 那么就会调用子类的方法(重写的父类的virtual方法)

父类指针(引用)

指针运算是按照指针所指的类型进行的

1
2
//p++ 等价于 p=p+1 
p = (unsigned int)basep + sizeof(*p); // 注意这里是 *p

因为父类和子类的数据成员不一样(虚表指针vptr也是数据成员之一), 所以父类指针 p++ 和子类指针 p++ 的长度是不一样.
(当用父类指针操作子类对象数组, 不要使用++, 应该使用index, 和[])

多态成立条件

详细探讨:

1. 有继承
2. 有(virtual)函数重写
3. 有父类指针(父类引用) 指向子类对象
4. 通过父类指针调用重写的父类的virutal方法

联编(绑定)

多态中使用的是动态联编, 即延迟绑定.

静态联编动态联编 是两种重要的编译技术

  • 联编是指一个程序模块、 代码之间互相关联的过程。
  • 静态联编(static binding), 是程序的匹配, 连接在编译阶段实现, 也称为早期匹配; 重载函数使用静态联编(直接指定了虚拟地址)
  • 动态联编是指程序联编推迟到运行时进行, 所以又称为晚期联编(迟绑定), 运行时需要寻址; switch 语句和 if 语句是动态联编的例子
  • 静态联编补充
    没有virtual限定的时候, 编译器默认认为父类指针就指向父类对象.由于程序没有运行, 所以不可能知道父类指针指向的具体是父类对象还是子类对象, 就是由于 静态联编 .
    从程序安全的角度, 编译器假设父类指针只指向父类对象, 因此编译的结果为调用父类的成员函数. 这种特性就是静态联编. 重载是在编译期间根据参数类型和个数决定函数调用, 又称为静多态; 而动多态是在运行时决定具体的调用.

多态和构造虚构

常被问道的问题如下:

1. 构造和析构能不能是虚函数; 
2. 构造或者析构中调用虚函数能不能有多态行为.
3. 虚表指针(VPTR)被编译器初始化的过程(其实只要依据virutal关键字)

回答这些问题就需要了解c++多态实现原理, 具体是指虚表(见下面).

下面直接说结论(简单解释):

  • 构造函数不能是虚函数; 析构函数应该是虚函数, 特别是存在virtual方法, 并且有子类继承的时候, 这个时候父类虚构器必须是虚函数, 否则通过父类指针(其指向子类对象)操作的情景下, 父类析构函数可以正常调用, 但是子类析构函数就不会被调用.
  • 析构函数调用虚函数不会形成多态(一般编译器是这样处理的: new关键字调用operator new()分配内存, 初始化成员, 编辑虚表), 因为对象的构建还没有完成, 主要是指虚表或者虚表指针的编辑工作还没有完成, 不会形成多态.
  • 析构函数中调用虚函数也不会形成多态, 并且这种行为很危险; 因为对象已经销毁了(内存已经释放了), 再通过该父类指针进行调用很危险.

构造函数或者析构函数调用虚函数不能形成多态的原因总结为 “有父类指针(父类引用)指向子类对象”, 或者说子类对象没有形成(已经析构, 没有实在的指向), 只有当对象的构造完全结束后 VPTR 的指向才最终确定.

虚表指针

  • 虚表
    如果类中存在虚函数, 那么编译时编译器就为该类生成虚表, 增加数据成员虚表指针(每个对象一个).

子类的虚表是在父类的基础上改善完成, 特别是子类存在virutal函数重写时, 重写时子类重写的函数排在虚表的前面, 父类的排在后面. 也就是说虚表如果是一个链表(或者数组), 它是头插入法, 并且虚表指针是一般指向虚表的头, 仅仅只有虚函数的函数入口地址会被加入虚表.

通过虚函数表指针 VPTR 调用重写函数是在程序运行时进行的, 因此需要通过 寻址操作 才能确定真正应该调用的函数(虚表遍历或者查找), 而普通成员函数是在编译时就确定了调用的函数. 在效率上, 虚函数的运行效率要低很多.

虚表的构建过程, 可以参考一下 “inside cpp obj model” 一书, 其实简单的过程就是一个数组或者链表的头插法, 然后运行时查找正确的函数的问题(首次最佳匹配).

  • 虚表指针

编译器如果发觉该类存在virtual函数, 那么会自动生成的对象成员变量. 通过 sizeof() 运算可以佐证其存在, 例如: (注意Linux下对齐问题)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <cstdio>
#include <iostream>

#pragma pack(4)

using std::cout;
using std::endl;

class A
{
public:
void printf()
{
cout<<"aaa"<<endl;
}

private:
int a;
};

class B
{
public:
virtual void printf()
{
cout<<"bbb"<<endl;
}

private:
int b;
};

class C
{
virtual void printf() {}
};

int main(void)
{
printf("sizeof(a):%d, sizeof(b):%d sizeof(c):%d\n",
sizeof(A), sizeof(B), sizeof(C)); //4, 8+8 align, 8
return 0;
}

抽象类部分

借助纯虚函数形成抽象类.

  • 纯虚函数
    虚函数的一种, 只不过这种函数只提供说明, 不提供实现, 语法上就是 =0;
    拥有纯虚函数的类就是抽象类, 不过C++这一块儿比较尴尬, 因为他没有纯接口类型的接口界面, 不像java有专门的interface关键字可以定义接口(类), 用来展示一组相关的API接口(公共访问界面), 而是采用了抽象类的机制, 从多继承的层面用 is-a 的关系来表达 has-a function的机制.

    下面说的接口, 在C++一律指 抽象类 .

    由于接口要小(暴露的要少), 把相关的方法放在一起, 其他相关的放在另外一个接口中, 所以会使用多继承, 从多个接口中引入相关的function或者称为feature; 但是引入多继承的同时会带来相当的复杂度(研发可能还好), 比如说二义性问题, 对于维护也是灾难性的, 所以实际开发中, 一般都建议采用单继承(当然也有特例), 并且但凡是抽象类, 就不提供数据成员, 只提供纯虚函数API.

  • 抽象类
    抽象类是不能用来初始化实例的, 只能是作为 抽象编程 的基础, 也就是说, 抽象类要么声明为指针, 要么是引用. 而让具体的子类提供纯虚函数的不同实现版本.

    应用的例子, 已经存在的模块提供好接口, 让外面的三方去实现响应的方法或者算法; 就像C语言里面的函数指针, 它定义好了相关的接口的入参出参以及返回值, 具体函数的实现交给外部, 以此达到解耦的目的(面向接口编程). (c语言多态就是通过函数指针来完成相同调用不同表现的)

    当然由于接口的引入还引入了 DIP, IOC, 注入, 组合, MVC, AOP等等一些列思想, 他们可能不同, 但是都是以面向抽象为基础.

尾巴

花了很大的力气, 才把 面向对象特征 说完.

不过c++还在继续的发展, 应该会引入更多的现代语言所拥有的特性. 当然也不排除在特定领域被具体的语言所压制, 蚕食, 然而以此为基础存在的编程思想, 风格, 软件短时间内却不会过时.

说到底, 本文所说的, 不过是基础. 是的, c++就是一门, 花了很大时间学习, 但用起来不见得能用好的语言.

笑.

文章目录
  1. 1. 引子
  2. 2. 正文
    1. 2.1. 封装
    2. 2.2. 构造和析构函数
    3. 2.3. 静态成员部分
    4. 2.4. C++对象模型部分
    5. 2.5. 友元部分
    6. 2.6. 运算符重载部分(重点)
    7. 2.7. 继承和派生部分
      1. 2.7.1. 多继承(虚拟继承)
    8. 2.8. 多态部分(重点)
      1. 2.8.1. virtual
      2. 2.8.2. 父类指针(引用)
      3. 2.8.3. 多态成立条件
      4. 2.8.4. 联编(绑定)
      5. 2.8.5. 多态和构造虚构
      6. 2.8.6. 虚表指针
    9. 2.9. 抽象类部分
  3. 3. 尾巴
|