跳过正文
P13 类
  1. S/
  2. C/C++/
  3. C/
  4. C语言程序设计/

P13 类

Li
作者
Li
往前走,别回头!
目录
C 语言程序设计笔记 - 这篇文章属于一个选集。

知识点
#

14.1 成员函数、对象复制与私有成员
#

  • @ 类基础图
    • ![[白板/P41 类基础.canvas|P41 类基础]]

14.1.1 总述
#

[!tip] 类的概念

  • @ : 一种自定义的数据类型,也就是一个新类型
  • 类与类之间不是彼此孤立的,一个类可以派生出子类
  • 设计类要站在多个角度考虑:
    • 1 设计和实现者角度:理顺数据存储布局,定义必要的成员变量和成员函数
    • 2 使用者角度:提供可访问的接口,隐藏内部实现细节
    • 3 父类设计角度:抽取公共特性放到父类中

14.1.2 类基础
#

[!tip] 类基础

  • @ : 一个用户自己定义的数据类型,可以想象成一个名字空间,包着一堆内容(成员函数、成员变量)
  • % 类的构成最常见的是成员变量成员函数两种
  • 访问成员的方式:
    • 用对象访问:对象名.成员名
    • 用指针访问:指针名->成员名
  • public 修饰符:提供类的访问接口,暴露给外界
  • private 修饰符:提供实现细节,不暴露给外界
  • struct 是成员默认为 publicclass
  • class 成员默认是 private
struct student {
    int number;
    char name[100];
    void func() {};
};

class A {
public:
    // ...公共成员
private:
    // ...私有成员
};
// struct A{...} 等价于 class A{public: ...};

14.1.3 成员函数
#

[!tip] 成员函数

  • @ 成员函数: 属于类的函数,与类有关联关系
  • 将函数设计为类的成员函数,两者就有关联关系
  • 成员函数可以在类定义内部直接实现(定义),也可以声明和实现分开
  • 类定义放在 .h 头文件中,成员函数实现放在 .cpp 源文件中
  • .cpp 中使用 类名::函数名 的作用域运算符表示函数属于哪个类
  • 类定义可以在多个 .cpp 文件中用 #include 重复包含,这是被允许的
// Time.h - 类定义
class Time {
public:
    int Hour;
    int Minute;
    int Second;
    void initTime(int tmphour, int tmpmin, int tmpsec); // 声明
};

// Time.cpp - 成员函数实现
void Time::initTime(int tmphour, int tmpmin, int tmpsec) {
    Hour = tmphour;
    Minute = tmpmin;
    Second = tmpsec;
}

14.1.4 对象的复制
#

[!tip] 对象的复制

  • @ 对象复制: 定义一个新对象时,用另外一个老对象里面的内容进行初始化
  • 复制后每个对象都有不同的地址,成员变量的值都相等
  • 可以使用 =(){} 等运算符进行对象复制
  • 默认情况下,类对象的复制是每个成员变量逐个复制
  • 可以通过定义"赋值运算符"来控制对象的复制行为
Time myTime2 = myTime;    // 对象复制
Time myTime3(myTime);     // 对象复制
Time myTime4{myTime};     // 对象复制
Time myTime5 = {myTime};  // 对象复制
Time myTime6;
myTime6 = myTime5;        // 通过赋值操作来复制对象

14.1.5 私有成员
#

[!tip] 私有成员

  • @ 私有成员: 类的私有成员变量和私有成员函数都只能在类的成员函数内调用,外界无法直接调用
  • 私有成员的设置目的是不暴露接口,只供类内部的其他成员函数使用
  • 在类定义内部,privatepublic 修饰符修饰其下面的所有成员,直到遇到其他修饰符
  • class 定义时默认所有成员为 private
  • 一个类的定义中可以出现多个 public、多个 private

14.2 构造函数详解、explicit 与初始化列表
#

  • @ 构造与析构图
    • ![[白板/P42 构造与析构.canvas|P42 构造与析构]]

14.2.1 称呼上的统一
#

[!tip] 成员函数的称呼

  • @ 成员函数的定义: 在 class 定义内部将成员函数完整写出(包括所有实现代码)
  • @ 成员函数的声明: 在 class 定义内部只写出函数声明(一般在 .h 文件中)
  • @ 成员函数的实现: 在 class 定义外部写出函数体代码(一般在 .cpp 文件中)

14.2.2 构造函数
#

[!tip] 构造函数

  • @ 构造函数: 名字与类名相同,在创建类对象时被系统自动调用的特殊成员函数
  • 构造函数的目的(存在的意义)就是==初始化类对象的数据成员==
  • ! 构造函数无返回值,函数头什么也不写(不是 void
  • ! 不可以手工调用构造函数
  • 构造函数应该声明为 public,否则无法直接创建该类的对象
  • 构造函数如果有参数,创建对象时也要指定相应参数
// 声明
public:
    Time(int tmphour, int tmpmin, int tmpsec);

// 实现
Time::Time(int tmphour, int tmpmin, int tmpsec) {
    Hour = tmphour;
    Minute = tmpmin;
    Second = tmpsec;
}

// 创建对象
Time myTime(12, 13, 52);    // 调用带参构造函数
Time myTime2{12, 13, 52};   // 调用带参构造函数

14.2.3 多个构造函数
#

[!tip] 多个构造函数

  • 一个类中可以同时存在多个构造函数,为对象创建提供多种方法
  • 多个构造函数之间必须在参数数量或参数类型上有所不同
  • 无参构造函数也叫默认构造函数
Time myTime10 = Time();   // 调用无参构造函数
Time myTime12;             // 调用无参构造函数,注意写法只有对象名
Time myTime13 = Time{};    // 调用无参构造函数
Time myTime14{};           // 调用无参构造函数

14.2.4 函数默认参数
#

[!tip] 函数默认参数

  • @ 默认参数: 如果生成对象时不给参数传值,该参数就使用默认值
  • 默认参数一般放在函数声明中(.h 文件中),不放在函数实现中
  • ! 默认参数都必须出现在非默认参数的右侧,一旦开始指定默认值,右侧所有参数都必须指定
Time(int tmphour, int tmpmin = 59, int tmpsec = 12); // 可以
Time(int tmphour, int tmpmin = 59, int tmpsec);      // 不可以

14.2.5 隐式转换和 explicit
#

[!tip] 隐式转换与 explicit

  • @ 隐式转换: 编译系统将一种数据类型自动转换为另一种类型(如将数字转换为类对象)
  • 单参数的构造函数会带来隐式转换问题
  • @ explicit: 在构造函数声明前加 explicit,禁止隐式转换,只能用于初始化和显式类型转换
  • ! 建议:单参数的构造函数都声明为 explicit,除非有特别原因
  • explicit 也可以用于无参或多个参数的构造函数
explicit Time(int tmphour);  // 禁止隐式转换

Time myTime100 = Time(16);    // 正常,显式转换
Time myTime101 = 16;          // 报错,隐式转换被禁止
func(16);                      // 报错,隐式转换被禁止
func(Time(16));                // 正常,显式转换

14.2.6 构造函数初始化列表
#

[!tip] 构造函数初始化列表

  • @ 初始化列表: 在构造函数定义中,用括号逗号式写法初始化成员变量,位于函数体之前执行
  • 提倡优先使用初始化列表的原因:
    • 1 写法更专业
    • 2 对于类类型的成员变量,使用初始化列表比赋值语句效率更高(少调用了一次构造函数等)
  • ! 成员变量的初始化顺序依据类定义中的定义顺序(从上到下),而非初始化列表的书写顺序
  • ! 避免在初始化列表中让某个成员变量的值依赖其他成员变量
// 初始化列表写法
Time::Time(int tmphour, int tmpmin, int tmpsec)
    : Hour(tmphour), Minute(tmpmin), Second(tmpsec)  // 初始化列表
{
    // 函数体
}

// 函数体内赋值写法
Time::Time(int tmphour, int tmpmin, int tmpsec) {
    Hour = tmphour;    // 赋值,不是初始化
    Minute = tmpmin;
}

14.3 inline、const、mutable、this 与 static
#

14.3.1 在类定义中实现成员函数 inline
#

[!tip] 内联成员函数

  • 直接在类定义中实现的成员函数会被当作 inline 内联函数来处理
  • 内联函数系统将尝试用函数体内的代码直接取代函数调用代码,以提高运行效率
  • ! 内联函数只是对编译器的建议,能否 inline 成功取决于编译器
  • 成员函数的定义体尽量写得简单,以增加被 inline 成功的概率

14.3.2 成员函数末尾的 const
#

[!tip] 常量成员函数

  • @ 常量成员函数: 在成员函数末尾增加 const,告诉系统该函数不会修改对象里面的任何成员变量
  • 声明和实现分开时,声明和实现中都要加 const
  • const 对象只能调用 const 成员函数
  • const 对象既可以调用 const 成员函数,也可以调用非 const 成员函数
  • ! 普通函数(非成员函数)末尾不能加 const,编译都无法通过
void noone() const {
    Hour += 10;  // 错误!常量成员函数不可以修改成员变量
}

const Time abc;       // 定义 const 对象
abc.addhour(12);      // 不可以,非 const 成员函数
abc.noone();          // 可以,const 成员函数

14.3.3 mutable
#

[!tip] mutable 修饰符

  • @ mutable: 与 const 正好是反义词,用于突破 const 的限制
  • 一个成员变量一旦被 mutable 修饰,就表示==永远处于可变状态==,即使是在以 const 结尾的成员函数中
  • 使用场景:在 const 成员函数中确实需要修改某个成员变量,但又不想去掉函数末尾的 const
mutable int myHour;  // mutable 成员变量

void noone() const {
    myHour += 3;  // 可以修改,因为 myHour 是 mutable 的
}

14.3.4 返回自身对象的引用——this
#

[!tip] this 指针

  • @ this: 用在成员函数中的一个隐藏的函数参数,表示指向本对象的指针
  • *this 表示调用该成员函数的那个对象(本对象)
  • this 是一个指针常量(不是常量指针),总是指向对象本身,不可以让 this 再指向其他地方
  • 在普通成员函数中,this类名 * const 类型
  • const 成员函数中,thisconst 类名 * const 类型
  • ! this 指针只能在成员函数中使用,全局函数、静态函数都不能使用
  • 返回 *this 可以实现函数串联调用
Time& Time::rtnhour(int tmphour) {
    Hour += tmphour;
    return *this;  // 返回对象自身
}

// 串联调用
mytime.rtnhour(3).rtnminute(4);  // 先调用 rtnhour,再调用 rtnminute

14.3.5 static 成员
#

[!tip] 静态成员

  • @ 静态成员变量: 不属于某个对象,而是属于整个类的成员变量
  • 所有该类的对象共享同一个副本
  • 可以用 类名::成员变量名对象名.成员变量名 的方式访问
  • ! 静态成员变量必须在类外某个 .cpp 源文件中定义(分配内存),定义时无须用 static
  • @ 静态成员函数: 不隶属于某个对象,而是隶属于整个类
  • 调用方式:类名::成员函数名(...)
  • ! 静态成员函数中只能操作 static 成员变量,不能操作普通成员变量
class Time {
public:
    static int mystatic;                    // 声明静态成员变量
    static void stafunc(int testvalue);     // 声明静态成员函数
};

int Time::mystatic = 5;  // 在 .cpp 中定义静态成员变量

// 使用
cout << Time::mystatic << endl;    // 用类名访问
mytimel.mystatic = 12;              // 用对象名访问
Time::stafunc(1288);                // 用类名调用静态成员函数

14.4 delete 与 default、类内初始化、默认构造函数
#

14.4.1 类相关非成员函数
#

[!tip] 类相关非成员函数

  • 有些功能函数和类有关系但不应该定义在类里面
  • 定义可以放在 .cpp 中,声明放在 .h 中类定义的后面
  • 形参用引用类型,避免对象复制产生的效率损失

14.4.2 类内初始值
#

[!tip] 类内初始值

  • C++11 新标准中,可以为成员变量提供类内初始值
  • 创建对象时,这个初始值用来初始化该成员变量
  • 如果使用构造函数初始化列表或在构造函数中给值,该值会覆盖掉初始值
class Time {
public:
    int Second{0};   // 类内初始值
    int Minute = 0;  // 类内初始值(两种写法都可以)
};

14.4.3 const 成员变量的初始化
#

[!tip] const 成员变量初始化

  • ! 类的 const 成员变量==只能使用初始化列表来初始化==,不能在构造函数内部赋值
  • 构造函数完成初始化之后,const 成员变量才真正具备 const 属性
  • ! 构造函数不能声明成 const 的,因为构造函数需要进行写值操作
const int testvalue;  // const 成员变量

Time::Time(int tmphour, int tmpmin, int tmpsec)
    : Hour(tmphour), Minute(tmpmin), testvalue(18)  // 必须用初始化列表
{
    testvalue = 6;  // 不可以!
}

14.4.4 默认构造函数
#

[!tip] 默认构造函数

  • @ 默认构造函数: 没有参数的构造函数
  • 如果一个类中没有构造函数,也能成功生成对象
  • 编译器只有在满足一定情形下才会生成"合成的默认构造函数"
  • ! 一旦程序员自己写了构造函数,编译器就不会生成"合成的默认构造函数"
  • 必须通过初始化列表初始化的情况:
    • 1 const 成员变量
    • 2 类类型成员变量(该类没有默认构造函数)

14.4.5 = default;= delete;
#

[!tip] = default 和 = delete

  • @ = default;: 让编译器为函数自动生成函数体(等价于空函数体),适用于特殊函数(默认构造函数、析构函数等)
  • 放在类声明中则具备 inline 特性,放在类定义外则不具备
  • @ = delete;: 显式禁用某个函数
  • = delete 可以禁止编译器生成"合成的默认构造函数"
class Time2 {
public:
    Time2() = default;      // 编译器自动生成默认构造函数
    Time2() = delete;       // 禁用默认构造函数
};

14.5 拷贝构造函数
#

[!tip] 拷贝构造函数

  • @ 拷贝构造函数: 第一个参数是所属类类型引用,若有额外参数则都有默认值的构造函数
  • 拷贝构造函数会在一定时机被系统自动调用
  • 一般第一个参数带 const 修饰
  • ! 一个类中只能有一个拷贝构造函数
  • 拷贝构造函数一般不声明为 explicit
  • 如果定义了自己的拷贝构造函数,就取代了编译器合成的拷贝构造函数,必须自己给成员逐个赋值
// 声明
Time(const Time& tmptime, int a = 0);

// 实现
Time::Time(const Time& tmptime, int a) {
    Hour = tmptime.Hour;
    Minute = tmptime.Minute;
    Second = tmptime.Second;
}

[!tip] 调用拷贝构造函数的时机

  • 1 用一个对象初始化另一个对象:Time myTime2 = myTime;
  • 2 将一个对象作为实参传递给一个非引用类型的形参
  • 3 从一个函数中返回一个对象
  • ! 以下不调用拷贝构造函数:myTime6 = myTime5;(这是赋值操作,调用拷贝赋值运算符)

14.6 重载运算符、拷贝赋值运算符与析构函数
#

14.6.1 重载运算符
#

[!tip] 重载运算符

  • @ 重载运算符: 以运算符名为成员函数名来写成员函数,实现运算符应用于类对象时的功能
  • 本质上是函数,函数名是 operator 关键字后面接运算符
  • 具有返回类型和参数列表
  • 如果不自己重载 =,某些情况下编译器会帮助重载赋值运算符
  • ! 如果不自己重载 ==,编译器不会帮助重载,编译一定报错
// 声明
bool operator==(Time& t);

// 实现
bool Time::operator==(Time& t) {
    if (Hour == t.Hour)
        return true;
    return false;
}

14.6.2 拷贝赋值运算符
#

[!tip] 拷贝赋值运算符

  • @ 拷贝赋值运算符: 给对象赋值时系统调用的运算符重载函数
  • 左侧对象就是 operator= 里的 this 对象
  • 形参用 const 类型,防止误改源对象
  • 返回值通常是指向左侧运算符对象的引用(return *this;
  • 如果想禁止对象间赋值,用 private 修饰赋值运算符重载声明
// 声明
Time& operator=(const Time&);

// 实现
Time& Time::operator=(const Time& tmpTime) {
    Hour = tmpTime.Hour;
    Minute = tmpTime.Minute;
    Second = tmpTime.Second;
    return *this;
}

14.6.3 析构函数
#

[!tip] 析构函数

  • @ 析构函数: 与构造函数正好相反,对象销毁时系统调用的成员函数
  • 名字由波浪线 ~ 连接类名构成,没有返回值,不接受任何参数
  • ! 一个给定的类只有唯一一个析构函数(不能被重载)
  • 如果不写自己的析构函数,编译器可能会生成"默认析构函数"
  • ! 在构造函数中 new 出来的内存,必须在析构函数中 delete 释放,否则内存泄漏
// 声明
~Time();

// 实现
Time::~Time() {
    // 释放资源
}

14.6.4 几个话题
#

[!tip] 构造函数的成员初始化

  • 构造函数做的事分两部分:函数体之前(初始化列表)和函数体之中
  • 成员变量初始化顺序按照类中定义的顺序,而非初始化列表的书写顺序
  • ! 对于类类型成员变量,能放在初始化列表中初始化的,千万不要放在函数体里赋值

[!tip] 析构函数的成员销毁

  • 析构函数做的事也分两部分:函数体之中和函数体之后
  • 成员变量不是在析构函数体里销毁的,而是函数体执行完成后由系统隐含销毁
  • 成员变量初始化顺序:先定义的先初始化;销毁顺序:先定义的后销毁
  • ! new 分配的内存必须自己 delete 释放,系统不会帮忙

[!tip] new 对象和 delete 对象

  • new 创建对象时系统调用构造函数
  • ! 自己 new 出来的对象,自己必须 delete 释放,否则内存泄漏
  • delete 时系统调用析构函数
  • ! newdelete 必须配对使用

14.7 子类、调用顺序、访问等级与函数遮蔽
#

  • @ 继承与多态图
    • ![[白板/P43 继承与多态.canvas|P43 继承与多态]]

14.7.1 子类概念
#

[!tip] 继承

  • @ 继承: 子类从父类那里继承成员变量和成员函数,是面向对象程序设计的核心思想之一
  • 子类比父类有更多的成员变量和成员函数,一般比父类更庞大
  • 定义子类的一般形式:class 子类名 : 继承方式 父类名
class Men : public Human {
public:
    Men();
};

14.7.2 子类对象定义时调用构造函数的顺序
#

[!tip] 构造函数调用顺序

  • ! 定义子类对象时,先执行父类构造函数的函数体,再执行子类构造函数的函数体
  • 销毁时顺序相反:先执行子类析构函数,再执行父类析构函数

14.7.3 访问等级(public、protected 与 private)
#

[!tip] 三种访问权限

  • @ public: 可以被任意实体所访问
  • @ protected: 只允许本类或者子类的成员函数来访问
  • @ private: 只允许本类的成员函数访问

[!tip] 继承方式与访问权限

父类中的访问权限子类继承方式子类得到的访问权限
publicpublic 继承public
protectedpublic 继承protected
privatepublic 继承子类无权访问
publicprotected 继承protected
protectedprotected 继承protected
privateprotected 继承子类无权访问
publicprivate 继承private
protectedprivate 继承private
privateprivate 继承子类无权访问
  • ! 父类中的 private 成员不受继承方式的影响,子类永远无权访问
  • public 继承:父类所有成员在子类中的访问权限都不发生改变
  • protected 继承:将父类中 public 成员变为子类的 protected 成员
  • private 继承:父类所有成员在子类中的访问权限变为 private

14.7.4 函数遮蔽
#

[!tip] 函数遮蔽

  • @ 函数遮蔽: 子类会遮蔽父类中的同名函数,不论此函数的返回值、参数
  • 只要子类中有一个和父类同名的成员函数,通过子类对象完全无法调用父类中的同名函数
  • 调用父类同名函数的方法:
    • 1 在子类成员函数中使用 父类名::成员函数名(...)
    • 2 在 main 中使用 子类对象名.父类名::成员函数名(...)
    • 3 使用 using 声明让父类同名函数在子类中可见
// using 声明
public:
    using Human::samenamefunc;  // 让父类的同名函数在子类中可见

// 调用
men.samenamefunc(1);   // 执行子类的函数
men.samenamefunc();     // 执行父类的不带参数的函数

14.8 父类指针、虚/纯虚函数、多态性与析构函数
#

14.8.1 父类指针与子类指针
#

[!tip] 父类指针与子类指针

  • 父类指针可以 new 一个子类对象:Human *phuman = new Men;
  • ! 子类指针不能 new 一个父类对象:Men *pmen = new Human;(报错)
  • 父类指针只能调用父类的成员函数,无法调用子类特有的成员函数

14.8.2 虚函数
#

[!tip] 虚函数

  • @ 虚函数: 在父类成员函数声明前增加 virtual 关键字,使父类指针可以调用子类的同名同参成员函数
  • virtual 关键字必须加在父类的成员函数声明中
  • 子类中是否加 virtual 没有强制要求,但建议加上
  • ! 子类的虚函数形参必须和父类的完全一致,否则被认为是不同的函数
  • @ override: C++11 关键字,用在子类虚函数声明末尾,编译器会检查是否正确覆盖了父类虚函数
  • @ final: 用在虚函数声明末尾,任何子类中尝试覆盖该函数的操作都将引发错误
// 父类
class Human {
public:
    virtual void eat();  // 虚函数声明
};

// 子类
class Men : public Human {
public:
    virtual void eat() override;  // override 检查是否正确覆盖
};

// 使用
Human *phuman = new Men;
phuman->eat();  // 调用 Men 类的 eat 函数(动态绑定)

[!tip] 动态绑定

  • @ 动态绑定: 程序运行时才能知道调用了哪个子类的虚函数
  • 虚函数必须写定义部分,否则编译出错
  • 如果用普通对象调用虚函数,则不需要动态绑定,编译时就能确定

14.8.3 多态性
#

[!tip] 多态性

  • @ 多态性: 针对虚函数说的,非虚函数不存在多态
  • 体现在具有继承关系的父类和子类之间,子类重新定义(覆盖/重写)父类的虚函数
  • 通过父类指针,到程序运行时期才能找到动态绑定的对象,从而调用正确的虚函数
  • 系统内部通过查"虚函数表"找到虚函数入口地址

14.8.4 纯虚函数与抽象类
#

[!tip] 纯虚函数与抽象类

  • @ 纯虚函数: 在父类中声明的虚函数,没有函数体,在函数原型后面加 = 0
  • @ 抽象类: 含有纯虚函数的类,不能用来生成对象,主要当作父类用来生成子类
  • ! 子类中必须实现父类的纯虚函数,否则该子类也是抽象类,无法创建对象
  • 抽象类的主要用途:统一管理子类,建立规范
class Human2 {
public:
    virtual void eat() = 0;  // 纯虚函数
};

class Human2_sub : public Human2 {
public:
    virtual void eat() {  // 子类必须实现纯虚函数
        cout << "Human2_sub::eat()" << endl;
    }
};

14.8.5 父类的析构函数一般写成虚函数
#

[!tip] 虚析构函数

  • ! 父类指针 new 子类对象后 delete 时,如果父类析构函数不是虚函数,==只会调用父类的析构函数==,不会调用子类的析构函数,导致内存泄漏
  • 将父类析构函数声明为 virtual 后,delete 父类指针时会先调用子类析构函数,再调用父类析构函数
  • ! 如果一个类要做父类,务必把析构函数写成 virtual 析构函数
  • 虚函数会增加内存和执行效率上的开销(虚函数表)
class Human {
public:
    virtual ~Human();  // 虚析构函数
};

// delete 父类指针时正确调用顺序
Human *phuman = new Men;
delete phuman;  // 先调用 Men 析构函数,再调用 Human 析构函数

14.9 友元函数、友元类与友元成员函数
#

14.9.1 友元函数
#

[!tip] 友元函数

  • @ 友元函数: 一个函数,通过声明为某个类的友元,可以访问该类的所有成员(包括 privateprotectedpublic
  • 友元函数声明不受 publicprotectedprivate 的限制
  • friend 关键字声明
class Men {
    friend void func(const Men& tmpmen);  // 友元函数声明
private:
    void funcmen2() const;
};

14.9.2 友元类
#

[!tip] 友元类

  • @ 友元类: 如果类 B 是类 A 的友元类,B 可以在其成员函数中访问类 A 的所有成员
  • ! 友元关系不能被子类继承
  • ! 友元关系是单向的(B 是 A 的友元,不代表 A 是 B 的友元)
  • ! 友元关系没有传递性(B 是 A 的友元,C 是 B 的友元,不代表 C 是 A 的友元)
class A {
    friend class B;  // 友元类声明
private:
    int data;
};

14.9.3 友元成员函数
#

[!tip] 友元成员函数

  • @ 友元成员函数: 只让另一个类中的某些成员函数成为本类的友元函数
  • 比让整个类成为友元类更精细
  • 写法需要注意代码组织结构,需要前向声明
// A.h
class A {
    friend void B::callBAF(int x, A& a);  // 声明 B 的成员函数为友元
private:
    int data;
};

// B.h
class A;  // 前向声明
class B {
public:
    void callBAF(int x, A& a);
};

[!tip] 友元的优缺点

  • 优点:允许特定情况下某些非成员函数访问类的 protectedprivate 成员
  • 缺点:破坏了类的封装性,降低了类的可靠性和可维护性
  • 面向对象三大特性:封装性继承性多态性

14.10 RTTI、dynamic_cast、typeid、type-info 与虚函数表
#

14.10.1 RTTI 是什么
#

[!tip] RTTI

  • @ RTTI(Run Time Type Identification):运行时类型识别
  • 通过运行时类型识别,程序能够使用父类的指针或引用来检查所指对象的实际子类类型
  • ! RTTI 的两个运算符要正常工作,父类中至少要有一个虚函数

14.10.2 dynamic_cast 运算符
#

[!tip] dynamic_cast

  • @ dynamic_cast: 能将父类的指针或者引用安全地转换为子类的指针或者引用
  • 转换成功说明指针实际上是要转换到的那个类型
  • 对于指针类型:转换失败返回 nullptr
  • 对于引用类型:转换失败抛出 std::bad_cast 异常
Human* phuman = new Men;
Men* pmen = dynamic_cast<Men*>(phuman);
if (pmen != nullptr) {
    cout << "phuman 实际指向一个 Men 类型对象" << endl;
}

// 引用类型
try {
    Men& ifmen = dynamic_cast<Men&>(myhuman_y);
} catch (bad_cast&) {
    cout << "转换失败" << endl;
}

14.10.3 typeid 运算符
#

[!tip] typeid 运算符

  • @ typeid: 返回一个 type_info 类型的常量对象引用,获取对象的类型信息
  • 两种形式:typeid(类型)typeid(表达式)
  • .name() 成员函数获取类型名字信息
  • ! 只有当父类含有虚函数时,typeid 才会对表达式求值(返回动态类型)
  • 如果父类没有虚函数,typeid 返回表达式定义时的类型(静态类型)
Human* phuman = new Men;
cout << typeid(*phuman).name() << endl;  // class Men(父类有虚函数时)

// 比较两个指针是否指向同一种类型
if (typeid(*phuman) == typeid(*phuman2)) {
    cout << "指向同一种类型对象" << endl;
}

14.10.4 type_info 类
#

[!tip] type_info 类

  • name() 成员函数:获取类型名字信息
  • ==!= 运算符:比较两个 type_info 对象是否表示同一种类型

14.10.5 RTTI 与虚函数表
#

[!tip] 虚函数表

  • 如果类含有虚函数,编译器会产生一个虚函数表
  • 虚函数表里每个表项是一个指针,指向虚函数的入口地址
  • 有些编译器在虚函数表中存放 type_info 对象信息

14.11 基类与派生类关系的详细再探讨
#

14.11.1 派生类对象模型简介
#

[!tip] 派生类对象模型

  • 派生类对象包含多个子对象:
    • 1 派生类自己定义的成员变量、成员函数的子对象
    • 2 派生类所继承的基类的子对象
  • 基类指针可以 new 派生类对象,因为派生类对象含有基类部分

14.11.2 派生类构造函数
#

[!tip] 派生类构造函数

  • 派生类不能直接初始化从基类继承的成员,而是使用基类的构造函数来初始化基类部分
  • 通过派生类构造函数的初始化列表向基类构造函数传递参数
  • 格式:类名(实参列表)
class B : public A {
public:
    B(int i, int j, int k) : A(i), m_valuec(k) {};  // 初始化列表中调用基类构造函数
};

14.11.3 既当父类又当子类
#

[!tip] 继承链

  • 一个类可以既是某个类的父类,同时又是另外一个类的子类
  • 派生类会包含直接基类的成员以及每个间接基类的成员

14.11.4 不想当基类的类
#

[!tip] final 类

  • C++11 中 final 关键字加在类名后面,该类不可以作为基类
class AA final {};         // 不可以作为基类
class BB : public AA {};   // 编译报错

14.11.5 静态类型与动态类型
#

[!tip] 静态类型与动态类型

  • @ 静态类型: 变量声明时的类型,编译时已知
  • @ 动态类型: 指针或引用所代表的内存中对象的类型,运行时才知道
  • 只有基类指针或引用才存在静态类型和动态类型不一致的情况

14.11.6 派生类向基类的隐式类型转换
#

[!tip] 隐式类型转换

  • 编译器会隐式执行派生类到基类的转换(因为派生类对象包含基类部分)
  • ! 不存在从基类到派生类的自动类型转换
  • 如果基类有虚函数,可以用 dynamic_cast 安全转换

14.11.7 父类、子类之间的复制与赋值
#

[!tip] 父子类复制与赋值

  • 用派生类对象为基类对象初始化或赋值时,只有该派生类对象的==基类部分==会被复制或赋值
  • 派生类部分将被忽略掉

14.12 左值、右值、左值引用、右值引用与 move
#

14.12.1 左值和右值
#

[!tip] 左值与右值

  • @ 左值: 能用在赋值语句等号左侧的内容(代表一个地址)
  • @ 右值: 不能作为左值的值(不能出现在赋值语句等号左侧)
  • 一个左值可能同时具有左值属性和右值属性
  • 左值在等号右侧时用的是值(右值属性),在等号左侧时用的是地址(左值属性)

14.12.2 引用分类
#

[!tip] 引用的三种形式

  • % 左值引用(绑定到左值):int&,引用希望改变值的对象
  • % const 引用(常量引用):const int&,引用不希望改变值的对象,可以绑定到右值
  • % 右值引用(绑定到右值):int&&,C++11 新概念,绑定到即将销毁/临时的对象

14.12.3 左值引用
#

[!tip] 左值引用

  • 引用必须初始化(绑定一个对象)
  • 左值引用不能绑定到右值上
  • const 引用可以绑定到右值上(比较特殊)
int a = 1;
int& b = a;       // 可以,绑定到左值
int& c = 1;       // 不可以,不能绑定到右值
const int& c = 1; // 可以,const 引用可以绑定到右值

14.12.4 右值引用
#

[!tip] 右值引用

  • @ 右值引用: 必须绑定到右值的引用,用 && 获得
  • 右值引用主要是用来绑定到那些"即将销毁/临时的对象"上
  • 引入目的是==提高程序运行效率==,把复制对象变成移动对象
  • ! 所有变量都要看成左值(有地址)
  • ! 任何函数里的形参都是左值
  • ! 临时对象都是右值
int&& refrightvalue = 3;  // 绑定到一个右值
string&& r5{"I love China!"};  // 绑定临时变量

int i = 10;
int&& ri5 = i * 100;  // 可以,乘法结果是右值
int& ri3 = i * 100;   // 不可以,左值引用不能绑右值

14.12.5 std::move 函数
#

[!tip] std::move

  • @ std::move: 把一个左值强制转换成一个右值,本身并没有做任何移动操作
  • move 函数名起得比较糟糕,它并没有移动能力
  • ! string def = std::move(st); 会触发 string 类的移动构造函数,st 变为空
  • ! string&& def = std::move(st); 不会触发移动构造函数,只是一个绑定动作
  • 调用 move 后,不应再对原对象的值做任何假设
int j = 10;
int&& ri20 = std::move(j);  // 将左值转换为右值
fff(std::move(j));           // 传递给右值引用形参

string st = "I love China!";
string def = std::move(st);  // 触发移动构造函数,st 变为空

14.13 临时对象深入探讨、解析与提高性能手段
#

14.13.1 临时对象的概念
#

[!tip] 临时对象

  • @ 临时对象: 系统产生的、程序员看不见的对象
  • 临时对象的产生和销毁都有成本,会影响程序执行性能和效率
  • 编写代码的原则:产生的临时对象越少越好
  • 临时对象就是一种右值

14.13.2 产生临时对象的情况和解决方案
#

[!tip] 以传值方式给函数传递参数

  • 以传值方式传递对象参数会调用拷贝构造函数创建副本
  • 解决方案:将形参改为引用类型

[!tip] 类型转换生成的临时对象

  • 隐式类型转换以保证函数调用成功时,编译器会产生临时对象
  • sum = 1000; 会先创建临时对象再赋值
  • 解决方案:直接用 CTempValue sum = 1000; 定义时初始化
  • ! C++ 只会为 const 引用产生临时对象,不会为非 const 引用产生临时对象

[!tip] 函数返回对象的时候

  • 函数返回一个对象时会产生临时对象
  • 如果有变量接收返回值,临时对象直接构造在该变量预留空间中(编译器优化)
  • 优化方案:return CTempValue(ts.val1 * 2, ts.val2 * 2); 直接返回构造的对象

[!tip] 类外的运算符重载之中的优化

  • 运算符重载函数中返回局部对象也会产生临时对象
  • 优化方案:return mynum(tmpnum1.num1 + tmpnum2.num1, ...); 直接返回构造的对象

14.14 对象移动、移动构造函数与移动赋值运算符
#

14.14.1 对象移动的概念
#

[!tip] 对象移动

  • @ 对象移动: 把一个不想用了的对象 A 中的一些有用数据提取出来,在构造新对象 B 时直接使用
  • 移动指的是把一块内存地址中数据的==所有者==从原来的标记为新的,而非搬移数据
  • 移动效率比复制高得多

14.14.2 移动构造函数和移动赋值运算符概念
#

[!tip] 移动构造函数

  • @ 移动构造函数: 第一个参数是右值引用(&&)的构造函数
  • 与拷贝构造函数的区别:形参是右值引用而非 const 左值引用
  • 移动构造函数中应该完成:
    • 1 让新对象接管源对象的资源
    • 2 斩断源对象与资源的关联(置 nullptr
    • 3 确保源对象处于可被销毁的状态
  • ! 不抛出异常的移动构造函数应加上 noexcept
A(A&& tmpa) noexcept : m_pb(tmpa.m_pb) {
    tmpa.m_pb = nullptr;  // 斩断源对象与内存的关联
}

[!tip] 移动赋值运算符

  • @ 移动赋值运算符: 形参为右值引用的赋值运算符重载
  • 同样应加上 noexcept
  • 需要处理自赋值情况
A& operator=(A&& src) noexcept {
    if (this != &src) {
        delete m_pb;          // 释放自己原来的内存
        m_pb = src.m_pb;      // 对方的内存直接靠过来
        src.m_pb = nullptr;   // 斩断源对象与内存的关联
    }
    return *this;
}

14.14.3 移动构造函数范例
#

[!tip] 移动构造函数调用时机

  • 系统发现调用移动构造函数更合适时(如生成临时对象的情形),会自动调用
  • 使用 std::move 将左值转为右值后,可以强制调用移动构造函数
  • ! A&& a2 = std::move(a); 不产生新对象,不调用移动构造函数,只是绑定

14.14.4 移动赋值运算符范例
#

[!tip] 移动赋值运算符调用

A a = getA();     // 移动构造
A a2;             // 普通构造
a2 = a;           // 拷贝赋值运算符
a2 = std::move(a); // 移动赋值运算符

14.14.5 合成的移动操作
#

[!tip] 合成的移动操作

  • ! 如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数(三者之一),编译器就不会为它合成移动构造函数和移动赋值运算符
  • 如果类中没有移动构造函数和移动赋值运算符,系统会调用拷贝构造函数和拷贝赋值运算符代替
  • 只有一个类没定义任何自己版本的拷贝构造函数、拷贝赋值运算符、析构函数,且每个非静态成员都可以移动时,编译器才会合成移动操作

14.14.6 总结
#

[!tip] 移动操作总结

  • 1 在有必要的情况下,应考虑给类添加移动构造函数和移动赋值运算符
  • 2 不抛出异常的移动构造函数、移动赋值运算符都应加上 noexcept
  • 3 程序员有责任使被移动数据的对象处于可被释放的状态
  • 4 如果类中没有移动操作,系统会调用拷贝操作代替

14.15 继承的构造函数、多重继承、类型转换与虚继承
#

14.15.1 继承的构造函数
#

[!tip] 继承的构造函数

  • C++11 中,派生类能够重用其直接基类定义的构造函数
  • 使用 using 父类名::父类名; 继承父类的构造函数
  • 编译器会把父类的每个构造函数都生成一个与之对应的子类构造函数(函数体为空)
  • ! 一个类只继承其直接基类的构造函数,不能继承间接基类
class B : public A {
public:
    using A::A;  // 继承 A 的构造函数
};

14.15.2 多重继承
#

[!tip] 多重继承

  • @ 多重继承: 从多个父类产生出子类,子类继承了所有父类的内容
  • 多重继承容易出现二义性问题(同名函数不明确调用哪个)
  • 解决方法:添加作用域明确调用,或在派生类中定义新版本
  • ! 能用单一继承解决的问题就不要使用多重继承

[!tip] 多重继承中的构造与析构

  • 构造函数执行顺序:按派生列表中基类的出现顺序
  • 析构函数执行顺序:与构造顺序相反
  • 每个类的构造函数初始化列表负责初始化它的直接基类

14.15.3 类型转换
#

[!tip] 多重继承中的类型转换

  • 基类指针可以指向派生类对象(编译器隐式执行派生类到基类的转换)
  • 多重继承中,每个基类指针都可以指向派生类对象

14.15.4 虚基类与虚继承
#

[!tip] 虚继承

  • @ 虚基类: 无论在继承体系中出现多少次,派生类中都只会包含唯一一个共享的该类子内容
  • @ 虚继承: 在继承方式前加 virtual 关键字
  • ! 所有从虚基类而来的派生类都要虚继承该类,一个都不能少
  • 虚基类的初始化工作由最底层的派生类来做
  • 虚基类子内容会被最先初始化(不管在继承体系中的位置)
class A : virtual public Grand {...};   // 虚继承
class A2 : public virtual Grand {...};  // 虚继承(顺序可互换)

class C : public A, public A2, public B {
public:
    C(int i, int j, int k) : A(i), A2(j), B(j), Grand(i), m_valuec(k) {};
    // 虚基类 Grand 由最底层派生类 C 来初始化
};

14.16 类型转换构造函数、运算符与类成员指针
#

14.16.1 类型转换构造函数
#

[!tip] 类型转换构造函数

  • @ 类型转换构造函数: 只有一个形参(非本类 const 引用)的构造函数,可以将其他类型数据转换成该类类型对象
  • 可以用 explicit 禁止隐式类型转换
class TestInt {
public:
    explicit TestInt(int x = 0) : m_i(x) {}  // 类型转换构造函数
    int m_i;
};

TestInt ti = 12;        // 隐式类型转换(无 explicit 时)
TestInt ti = TestInt(12); // 显式类型转换

14.16.2 类型转换运算符
#

[!tip] 类型转换运算符

  • @ 类型转换运算符: 将一个类类型对象转成某个其他类型数据的特殊成员函数
  • 一般形式:operator 类型名() const;
  • 没有形参,不能指定返回类型,但会返回对应类型的值
  • 必须定义为类的成员函数
  • ! 请谨慎使用,建议只在类类型与目标类型之间存在明显关系时才使用
operator int() const {
    return m_i;  // 将本类对象转成 int 类型
}

int k = ti2 + 5;  // 隐式调用 operator int()
int k2 = ti2.operator int() + 5;  // 显式调用

[!tip] 显式的类型转换运算符

  • explicit 关键字后,不能隐式转换
  • 需要使用 static_cast 进行显式转换
explicit operator int() const { return m_i; }
int k = static_cast<int>(ti2) + 5;  // 显式转换

14.16.3 类型转换的二义性问题
#

[!tip] 类型转换二义性

  • ! 类类型转换建议少用,容易产生二义性问题
  • 如果类中定义了多个类型转换运算符(如 operator int()operator double()),可能出现二义性
  • 如果两个类都有从 int 的类型转换构造函数,重载函数调用时也可能二义性

14.16.4 类成员函数指针
#

[!tip] 类成员函数指针

  • 普通成员函数指针:void (类名::*指针变量名)(参数列表)
  • 获取地址:&类名::成员函数名
  • ! 必须绑定到类对象才能调用
  • 静态成员函数指针:void (*指针变量名)(参数列表),不需要绑定对象
void (CT::*myfpointpt)(int) = &CT::ptfunc;  // 普通成员函数指针
(ct.*myfpointpt)(100);       // 对象调用
(pct->*myfpointpt)(200);     // 指针调用

void (*myfpointstatic)(int) = &CT::staticfunc;  // 静态成员函数指针
myfpointstatic(100);  // 直接调用

14.16.5 类成员变量指针
#

[!tip] 类成员变量指针

  • 普通成员变量指针:类型 类名::*指针变量名
  • 获取地址:&类名::成员变量名
  • 普通成员变量指针存储的是偏移量,不是真正的内存地址
  • 静态成员变量指针:存储的是真正的内存地址
int CT::*ap = &CT::m_a;  // 普通成员变量指针(偏移量)
ct.*ap = 189;             // 通过指针修改成员变量

int* stcp = &CT::m_stca;  // 静态成员变量指针(真正地址)
*stcp = 196;
Reply by Email
C 语言程序设计笔记 - 这篇文章属于一个选集。

相关文章