信息学竞赛 CPP 对象篇

面向对象概观

章节二:信息学竞赛 CPP 对象篇 - 图1

  • 最早的面向对象语言 simula67
  • 影响深远的 smalltalk(squeak/phora),lisp
  • 中规中矩的 Java / c#
  • 灵活易用的 python / ruby
  • 原型路线的 javascript / lua

image-20211228093948036

面向过程、面向对象思路上的差异

面对一个问题时,

  • 面向过程: 第一步做什么?然后再做什么?

  • 面向对象:

    1. 应该有哪些实体一起来完成这个任务?
    2. 这些实体可以归为哪几个类型?
    3. 每个实体应该具有什么样的功能?
    4. 实体间该如何交换信息?

面向对象的思想是:模拟客观世界中的事物,来构造软件系统。

为什么要引入面向对象?

  • 如何增强软件的重用性?
    • 基本的拷贝/粘贴?
    • 模式上的重复,提取为函数?
    • 例如,排序算法如何抽象出来,以供重用?
  • 软件的可维护性
    • 可读性。 文档。实体间的联系。
    • 可修改性,可测试性
    • 可以只扩充,不修改吗?
  • 用户的需求模糊,且不断变更,不断演化
    • 过程化的自顶向下很难应付
    • 对象模型是自底向上的思想

面向对象的特征

  • 抽象
  • 封装性。隐藏细节、结构、实现方式
  • 继承。 c++支持多重继承
  • 多态。 面向对象的精髓所在。

面向对象与基于对象

基于对象的语言,焦点在:使用对象。体现封装性。

有些无法派生出用户的“新类型”,当然就没法多态了。

有些不支持动态多态。当然,这样好处是:轻量级。注重效率,避免复杂化。

golang

面向对象关联

OOA,OOD,OOP

《UML》 统一建模语言,统一了Booch, OMT, OOSE 的表示方法,并扩展

工程标准,必学。

课程用到了:类图,对象图,活动图,时序图等。

类图示例

章节二:信息学竞赛 CPP 对象篇 - 图3

时序图示例

章节二:信息学竞赛 CPP 对象篇 - 图4

《设计模式》 23个经典模式及其衍生。对如何抽象和剥离有重要指导意义。

课程中会有部分涉及。

实时效果反馈

1. 关于面向对象编程语言,说法正确的是:__

  • A C++是纯面向对象语言,不能面向过程编程。

  • B c++是单继承模式,不能继承多个父类型。

  • C c++使用基于原型(prototype)的对象模型

  • D c++支持封装,继承,多态的面向对象特征

2. 相比于面向过程,使用对象的优势:__

  • A 代码执行效率高,运行更快。

  • B 更节省内存空间,适合于嵌入式环境

  • C 大型工程项目更易于管理和维护,可重用性更好。

  • D 很容易跨平台,可移植性更好。

答案

1=>D 2=>C

类与对象

image-20211228125142431

类是图纸,是蓝图,对象是实体,是房屋。

类不在内存。对象在内存。

通过类可以创建对象。

通过1个类,可以创建多个对象。 1 : n

类的语法

  1. class 类名 {
  2. 访问权限修饰符(private, public,protected):
  3. 成员数据;
  4. 成员函数;
  5. };

class 是 struct 升级版

c++统一了class与struct,用struct定义类也可以,区别是:默认为public修饰符

成员变量,成员函数

也称为:

属性(property),方法(method)

类的定义,体现了封装性。

章节二:信息学竞赛 CPP 对象篇 - 图6

类实现了:功能与实现的分离,实现了功能与数据结构的分离。

【示例】定时器

功能:

  • 设置倒计时:分,秒
  • tick() 走 1 秒
  • 读取当前剩余:分,秒
  1. // 定时器的定义
  2. class Timer
  3. {
  4. public:
  5. void set(int min, int sec);
  6. void tick();
  7. int get_min();
  8. int get_sec();
  9. private:
  10. int min;
  11. int sec;
  12. };
  13. ////// 定时器的实现
  14. void Timer::set(int m, int s)
  15. {
  16. min = m;
  17. sec = s;
  18. }
  19. // 走1秒
  20. void Timer::tick()
  21. {
  22. if(sec>0){
  23. sec--;
  24. }
  25. else if(min>0){
  26. min--;
  27. sec = 59;
  28. }
  29. if(min==0 && sec==0)
  30. cout << "beep ... beep ... " << endl;
  31. }
  32. // 读取时间
  33. int Timer::get_min() { return min; }
  34. int Timer::get_sec() { return sec; }

使用方法:

  1. Timer a;
  2. a.set(1,15);
  3. a.tick();
  4. cout << a.get_min() << "," << a.get_sec() << endl;
  5. for(int i=0; i<80; i++) a.tick();

当我们改变Timer实现机制时,只要功能不变,并不需要通知调用方。

  1. // 定时器
  2. class Timer
  3. {
  4. public:
  5. void set(int min, int sec);
  6. void tick();
  7. int get_min();
  8. int get_sec();
  9. private:
  10. int sec;
  11. };
  12. /////// 实现
  13. void Timer::set(int m, int s)
  14. {
  15. sec = m * 60 + s;
  16. }
  17. // 走表
  18. void Timer::tick()
  19. {
  20. if(sec>0) sec--;
  21. if(sec==0)
  22. cout << "beep ... beep ... " << endl;
  23. }
  24. // 读数
  25. int Timer::get_min() { return sec/60; }
  26. int Timer::get_sec() { return sec%60; }

对象初始化

如果不对对象进行初始化,则成员变量值不确定

初始化途径:

  • 定义成员变量时给初始值
  • 创建对象时,用 { } 语法

  • 构造函数(构造子)

实时效果反馈

1. struct 与 class 关键字的区别是:__

  • A struct 默认成员为public, class默认为private

  • B struct 中无法定义成员函数

  • C struct 中无法定义private成员

  • D struct 中无法初始化成员变量

2. 如果不对对象的成员数据进行初始化,其值为:__

  • A 0

  • B 系统默认值

  • C 编译器指定值

  • D 不确定

答案

1=>A 2=>D

对象与指针

章节二:信息学竞赛 CPP 对象篇 - 图7

对象是值语义

对象可以包含指针成员,它指向的东西不计入对象大小

指针可以指向对象,也可以当作对象数组来操作

对象内部的指针

  • 指针指向的目标并不属于对象“领地”
  1. class MyA
  2. {
  3. int x;
  4. char* p; // 指向的东西不在对象里
  5. };

对象的复制会造成指针牵连现象

  • 对象内部指针指向动态内存要小心(设计不好,就会泄漏)

==内存泄漏==过程示例

  1. class MyA
  2. {
  3. public:
  4. void set(char* name, int age){
  5. x = age;
  6. p = new char [strlen(name)+1];
  7. strcpy(p,name);
  8. }
  9. private:
  10. int x;
  11. char* p;
  12. };

使用过程:

  1. MyA a;
  2. a.set("zhangsan", 10);
  3. cout << sizeof(MyA) << endl;

修正:

  1. class MyA
  2. {
  3. public:
  4. void set(char* name, int age){
  5. x = age;
  6. p = new char [strlen(name)+1];
  7. strcpy(p,name);
  8. }
  9. void finish(){
  10. delete [] p;
  11. }
  12. private:
  13. int x;
  14. char* p;
  15. };

指向对象的指针

通过指针来调用对象的成员函数,或访问成员变量

指针的运算是基于sizeof(对象)的

与一般指针无异,可以当作数组来使用

对象引用

在本质上,还是指针操作,形式上,可以当作别名。

实时效果反馈

1. 关于对象内部的指针成员,说法正确的是:__

  • A 指针成员指向的数据也计算在sizeof(本对象)内

  • B 指针成员指向动态内存时,当对象释放时,也会自动释放

  • C 指针成员不能为NULL

  • D 指针成员在对象复制时,只是复制指针本身,不包含其指向的东西

2. 关于对象引用,说法正确的是?:__

  • A 对象引用可以理解为对象别名

  • B 对象引用比对象指针操作速度更快

  • C 通过对象引用无法修改对象的内容,只能读取

  • D 对象引用本质是指针,其类型的 sizeof 为 4 字节。

答案

1=>D 2=>A

指针赋值与对象拷贝

章节二:信息学竞赛 CPP 对象篇 - 图8

指针赋值或者对象拷贝都可能引发指针牵连

这是复杂性的开端,

也是bug的发源地。

  • 指针赋值

image-20211229131043120

  1. class MyA{
  2. public:
  3. int x;
  4. int y;
  5. void show(){
  6. printf("(%d,%d)\n", x, y);
  7. }
  8. };

其实,就是从前的 Point 定义(增加了show函数)

存在问题的使用方法:

  1. MyA* p = new MyA{10,20};
  2. MyA* q = p; // 形式上的拷贝,不管内容的含义
  3. q->x++;
  4. p->show(); // ?? 可能觉得奇怪
  5. delete p;
  6. delete q; // bug

更有甚者,可能会有另一个问题:

  1. MyA* p = new MyA{10,20};
  2. MyA* q = new MyA{30,40};
  3. q = p; // 苦乐不均啊!!
  4. delete p;
  5. delete q;
  • 对象赋值(对象拷贝)
  1. MyA* p = new MyA{10,20};
  2. MyA q = *p; // 这个是对象内存区的形式拷贝(memcpy)
  3. p->x++; // 不会产生牵连
  4. q.show();
  5. delete p; // 与 q 无关

但是。。。。

如果对象中有指针项,问题就复杂了。

  1. class LinkNode{
  2. public:
  3. int data;
  4. LinkNode* next;
  5. };
  1. LinkNode a{1,NULL};
  2. a.next = new LinkNode{2,NULL};
  3. LinkNode b = a;
  4. // 这次如何释放变成了难题。。。。

内存图景:

image-20211229135348168

实时效果反馈

1. 当对对象指针进行赋值时,发生了什么?__

  • A 两个指针的值不同,但指向对象内容相同

  • B 两个指针值相同,指向同一个对象

  • C 两个指针值相同,指向不同对象。

  • D 两个指针值不同,指向不同对象。

2. 对象默认的拷贝行为,说法正确的是:__

  • A 不能把堆空间的对象拷贝到栈空间

  • B MyA b = a; 把 b 原来的内存信息抹掉了,换成了与 a 相同的信息

  • C 当对象赋值 b=a; 时,如果b对象中有指针指向动态内存,则会自动回收。

  • D 对象赋值比指针赋值速度快

答案

1=>B 2=>B

浅拷贝与深拷贝

image-20211228204424637

如果不满意默认的拷贝行为,可以自己定义copy函数

对象对其动态内存的管理

例如,如何复制一个链表?

  1. class LinkNode{
  2. public:
  3. int data;
  4. LinkNode* next;
  5. void add(LinkNode* it){
  6. it->next = next;
  7. next = it;
  8. }
  9. void clear(){
  10. if(next==NULL) return;
  11. next->clear();
  12. delete next;
  13. next = NULL;
  14. }
  15. void show(){
  16. cout << data << " ";
  17. if(next) next->show();
  18. }
  19. LinkNode copy(); // 深度拷贝
  20. };

猜测一下结果:

  1. LinkNode a = {-1,NULL};
  2. for(int i=9; i>=1; i--) a.add(new LinkNode{i,NULL});
  3. //LinkNode b = a;
  4. a.show(); cout << endl;
  5. a.clear();
  6. a.show(); cout << endl;

这样呢?

  1. LinkNode a = {-1,NULL};
  2. for(int i=9; i>=1; i--) a.add(new LinkNode{i,NULL});
  3. LinkNode b = a;
  4. b.show(); cout << endl;
  5. a.clear();
  6. b.show(); cout << endl;

默认行为:

章节二:信息学竞赛 CPP 对象篇 - 图12

我们来实现深度拷贝

拷贝会返回临时对象,不是对象指针。

  1. class LinkNode{
  2. public:
  3. int data;
  4. LinkNode* next;
  5. void add(LinkNode* it){
  6. it->next = next;
  7. next = it;
  8. }
  9. void clear(){
  10. if(next) next->clear();
  11. delete next;
  12. next = NULL;
  13. }
  14. void show(){
  15. cout << data << " ";
  16. if(next) next->show();
  17. }
  18. LinkNode copy(); // 深度拷贝
  19. };
  20. LinkNode LinkNode::copy()
  21. {
  22. LinkNode t = {data, NULL};
  23. LinkNode* p = next;
  24. LinkNode* q = &t;
  25. while(p){
  26. q->next = new LinkNode{p->data, NULL};
  27. p = p->next;
  28. q = q->next;
  29. }
  30. return t;
  31. }

实时效果反馈

1. 在定义copy成员函数时,如果返回局部变量对象的指针,会怎样?

  • A 等到调用方拿到指针的时候,指针指向的对象已经是垃圾区了。

  • B 可以安全操作,不要忘记了释放它指向的内存

  • C 可以安全操作,不用释放内存,系统会自动释放

  • D 产生编译错误

2. 在定义copy成员函数时,如果返回一个动态申请的对象的指针,会怎样?__

  • A 调用方无法释放头节点的内存

  • B 调用方如果采用对象赋值来接收拷贝结果,会使头节点内存泄漏

  • C 调用方只要不忘记调用clear, 就不会内存泄漏

  • D 会造成两个链表内存牵连而不独立。

答案

1=>A 2=>B

成员函数与this指针

image-20211228213028508

疑惑

  1. class Timer{
  2. public:
  3. void set(int x);
  4. int get();
  5. void tick();
  6. private:
  7. int sec;
  8. };

问题:sec变量在哪里?

  1. void Timer::set(int x)
  2. {
  3. sec = x; // sec从何而来?类里?
  4. }
  5. int Timer::get()
  6. {
  7. return sec; // 与上一个sec是不是同一个变量?
  8. }

问题:到底是一个sec,还是多个?存在哪里?什么时候出生的,什么时候释放?

  1. Timer a;
  2. a.set(10);
  3. Timer b;
  4. b.set(20);
  5. cout << a.get() << endl;

成员函数与普通函数的区别

成员函数是c++新创造的事物吗?

如果没有面向对象,c语言能实现这个功能吗?

我们可以!

多设计一个参数,就实现了一切!

  1. struct T{
  2. int sec;
  3. };
  4. void set(T* that, int x)
  5. {
  6. that->sec = x;
  7. }
  8. int get(T* that)
  9. {
  10. return that->sec;
  11. }

调用处:

  1. T a;
  2. set(&a, 10);
  3. cout << get(&a) << endl;

this的身份

this 是编译器隐藏传入的一个指针

是常量,不能更改

无法重新赋值或进行指针运算

它表达的含义:当前正在哪个对象上工作

就是说:this 象征者当前对象

==输出当前对象的地址来看一看==

有什么大用?我不知道有妨碍吗?

很多用处,但现阶段…..

  • 当名字冲突时,可以明确指定

    1. void Timer::set(int sec)
    2. {
    3. //sec = sec;
    4. this->sec = sec;
    5. }
  • 要求返回当前对象本身该怎么写呢?

    1. Timer* Timer::tick()
    2. {
    3. if(sec>0) sec--;
    4. return this;
    5. }

    还可以:

    1. Timer& Timer::tick()
    2. {
    3. if(sec>0) sec--;
    4. return *this;
    5. }

实时效果反馈

1. 关于 this 的说法,正确的是:__

  • A this 是系统定义的静态变量,并且隐藏的。

  • B this 可以被重新赋值

  • C this 是系统隐含传入的一个形参变量

  • D this 不可以作为返回值

2. this 的哪个用途不能用其它办法代替? __

  • A 形参名与成员数据名相同

  • B 在成员函数中,调用当前对象的成员函数

  • C 输出当前对象的内存地址

  • D 返回当前对象的引用,以支持链式调用。

答案

1=>C 2=>D

构造子与析构子

章节二:信息学竞赛 CPP 对象篇 - 图14

对象出生时,内存是一片垃圾,这在有的场合无法忍受。

怎样==强制==用户必须初始化对象呢?

构造函数的目的

确保对象在任何场合被创建时,都是处于合理的状态(不是垃圾堆数据)

构造函数的语法:

  1. 与类同名

  2. 没有返回值(不是void)

  3. 可以有多个(重载 overload)

    之所以称为:构造子,而不说构造函数,是因为它与一般函数不同,不能随意调用

    构造函数,析构函数是系统自动调用的,我们无法控制什么时候调用或不调用

当我们不定义构造函数时,系统提供一个无参默认构造,什么都不干。

当定义任意构造函数,表示用户自己接手,系统默认提供的收回。

  1. class LinkNode{
  2. public:
  3. LinkNode(int x){
  4. data = x;
  5. next = NULL;
  6. }
  7. int get(){ return data; }
  8. private:
  9. int data;
  10. LinkNode* next;
  11. };

这时,如果:

  1. LinkNode a; // 会报错,因为没有找到构造函数

可以补充一个无参构造:

  1. LinkNode(){
  2. data = 0;
  3. next = NULL;
  4. cout << "..LinkNode().." << endl;
  5. }

我们可以观察它什么时候被调用

  1. LinkNode a;
  2. a = LinkNode{};

对象创建的场合很多,经常是:防不胜防。

我们怎么知道对象有多少个?

析构函数追踪每个对象的消亡。

  1. ~LinkNode(){
  2. cout << "..LinkNode...die..." << endl;
  3. }

析构函数的目的

对象生前持有的==资源==在临终前,一定要归还,不管是何种原因死亡。

资源是广义,不一定是内存,还可能:文件句柄,画刷,socket等

其目的是:强制性 归还资源

比如,单链表,怎样保证它在销毁时不要忘记释放堆内存资源。

  1. class LinkNode{
  2. public:
  3. LinkNode(int x){
  4. data = x;
  5. next = NULL;
  6. }
  7. ~LinkNode(){
  8. cout << "destroy... " << endl;
  9. if(next) delete next;
  10. next = NULL;
  11. }
  12. LinkNode& add(int x){
  13. LinkNode* p = new LinkNode(x);
  14. p->next = next;
  15. next = p;
  16. return *p;
  17. }
  18. void show(){
  19. LinkNode* p = this;
  20. while(p){
  21. cout << p->data << " ";
  22. p = p->next;
  23. }
  24. cout << endl;
  25. }
  26. private:
  27. int data;
  28. LinkNode* next;
  29. };

使用方法:

  1. LinkNode head(-1);
  2. head.add(1).add(2).add(3);
  3. head.show();

实时效果反馈

1. 关于构造子的用法,说法正确的是:__

  • A 用户必须在适当的时候调用构造子

  • B 用户定义有参的构造子,系统提供无参的构造子

  • C 构造子是系统自动调用的,用户无法调用

  • D 有参的构造子必须调用无参的构造子

2. 关于析构函数,说法正确的是:__

  • A 其作用是:把对象的状态恢复到默认值

  • B 在对象销毁前,提供必要的收尾工作

  • C 把对象中的所有指针置为NULL

  • D 有多少个构造子,就对应多少析构子

答案

1=>C 2=>B

构造函数重载

image-20211230110900716

析构函数只有一种形式,构造函数却经常花样百出。

构造函数经常有多个,为了不同的场合。

一般,无参构造一定要提供。

构造函数的花样

  • 使用参数的默认值,来简化重载

    1. class Circle{
    2. public:
    3. Circle(){
    4. cout << "init Circle()" << endl;
    5. }
    6. // Circle(int x, int y){
    7. // cout << "init circle(int, int)" << endl;
    8. // }
    9. Circle(int x, int y, int r=1){
    10. cout << "init circle(int, int, int)" << endl;
    11. }
    12. };
  • 在一个构造函数中调用另一个构造函数

    1. Circle(int x, int y, int r=1){
    2. Circle();
    3. cout << "init circle(int, int, int)" << endl;
    4. }
  • 抄袭已有对象创建新对象

    1. Circle(Circle* x){
    2. cout << "init circle(Circle*)" << endl;
    3. }

初始化列表

当一个对象中包含另一个对象时…..

包含指针不算,是包含完整的对象

image-20211231100756908

观察初始化的顺序等行为:

  1. // 平面直角坐标位置
  2. class Point{
  3. public:
  4. Point(){
  5. cout << "init point()" << endl;
  6. x = 0;
  7. y = 0;
  8. }
  9. Point(int x, int y){
  10. cout << "init point(x, y)" << endl;
  11. this->x = x;
  12. this->y = y;
  13. }
  14. void set(int x, int y){
  15. this->x = x;
  16. this->y = y;
  17. }
  18. int getx() { return x; }
  19. int gety() { return y; }
  20. private:
  21. int x;
  22. int y;
  23. };
  24. // 平面上的圆形(圆心 + 半径)
  25. class Circle{
  26. public:
  27. Circle(){
  28. cout << "init circle()" << endl;
  29. pt.set(0,0);
  30. r = 1;
  31. }
  32. Circle(int r){
  33. cout << "init circle(int)" << endl;
  34. Circle();
  35. this->r = r;
  36. }
  37. void show(){
  38. printf("circle: (%d,%d)%d\n", pt.getx(),pt.gety(),r);
  39. }
  40. private:
  41. Point pt; // 圆心
  42. int r; // 半径
  43. };

比较如下创建对象:

  1. //Circle a; //此时,Point对象初始化几次?
  2. Circle a(10); // Point对象初始化时机和次数?
  3. //a.show();

如何在外层对象初始化之前,对其包含对象初始化??

比如,强制 圆心在 (1,1)

  1. Circle():pt(1,1),r(10){
  2. cout << "init circle()" << endl;
  3. //pt.set(0,0);
  4. //r = 1;
  5. }
  6. Circle(int r):Circle(){ //基于另一构造函数,完成本构造
  7. cout << "init circle(int)" << endl;
  8. //Circle(); //这样不好,会引发内部对象多次初始化
  9. this->r = r;
  10. }

非对象类型放在初始化列表里,没意义,但可以这么做。

实时效果反馈

1. 关于构造函数的说法,正确的是:__

  • A 构造函数的返回值类型为: void,即,无返回值。

  • B 构造函数可以重载,只要参数个数不等即可。

  • C 构造中,可以调用另外一个版本的构造函数

  • D 构造函数中,可以调用析构函数

2. 什么情况下,需要使用初始化列表:__

  • A 可用可不用,与在构造函数中写没有太大区别

  • B 当一个对象中含有其它对象时,需要在本对象初始化前为嵌入的对象初始化

  • C 建议所有成员数据都用初始化列表,这样效率高

  • D 当包含了另一个对象的指针或引用的时候

答案

1=>C 2=>B

对象的生存期

image-20211228210722628

对象可以存在于栈,静态空间,或者堆内存中。

用于测试的类型 T,只有一个析构函数,必须是public

  1. class T{
  2. public:
  3. ~T(){
  4. cout << "destroy .. T .. " << endl;
  5. }
  6. };

​ 其对象大小为1字节。

  • 栈中的对象

    局部变量,在栈中分配,起始于左大括号,终于右大括号。

    对象被自动创建,自动释放,自动调用构造,自动调用析构。

  • 立即数

    在表达式等语句中临时出现的对象。生存期只限于本语句。

    1. cout << "111" << endl;
    2. T{};
    3. cout << "222" << endl;
  • for 全局

    1. for(T a;0;){
    2. }
  • for 函数体

    1. for(int i=0; i<5; i++){
    2. T a;
    3. }
  • 形式参数

    1. void f(T x){ }
    2. //。。。。调用:
    3. T a;
    4. f(a);

    如果,传入的是 T&,则本质上是传 T*, 这不会引发创建新对象

  • 返回值

    1. T f(){
    2. T a;
    3. return a;
    4. }
    5. // 。。。。 调用:
    6. T x;
    7. x = f();

    这在不同的环境表现不同。

    有时,2个对象,有时 3 个对象。

    看看极端情况:

    1. T f(){}
    2. // 调用。。。。
    3. f(); f();
  • 静态对象

    1. void f(){
    2. static T a;
    3. }
    4. // 调用。。。。。
    5. f(); f();
  • 对象动态申请与释放

    new 类型 [ ]; 返回对象指针

    delete [ ] 指针; 释放对象内存

    当使用对象数组的时候,必须用 delete [] p; 的形式

    1. T* p = new T[5];
    2. delete [] p;

    如果忘记了 [ ],则落入著名的大坑,造成内存泄漏

实时效果反馈

1. 当形参是对象引用类型时,说法正确的是:__

  • A 调用函数会创建新的对象

  • B 调用函数不会创建新对象,这点上,与对象指针类型的形参表现相同。

  • C 调用时,会创建新对象,但函数结束不会自动调用析构函数

  • D 不确定,与编译器的设置有关

2. 当动态分配对象数组时,对应的 delete 语句忘记了方括号,会怎样?__

  • A 只释放了一个对象的内存,造成内存泄漏。

  • B 内存可以释放,但只调用了一个析构函数。

  • C 内存可以释放,但不会调用任何析构函数

  • D 会引发语法错误

答案

1=>B 2=>B

对象的传递

image-20211228212005824

【示例对象】 计数器对象

  1. class Cnt{
  2. public:
  3. Cnt(){ x = 0; }
  4. ~Cnt() { cout << "destroy .. counter ... " << x << endl; }
  5. void inc() { x++; }
  6. void dec() { x--; }
  7. int get() { return x; }
  8. private:
  9. int x;
  10. };
  11. // 使用方法:。。。
  12. Cnt a;
  13. a.inc();
  14. a.inc();
  15. cout << a.get() << endl;
  • 传递指针

    最常见的情况是,主调方持有对象,把指针传入函数,希望操纵它

    一般被调方没有管理其内存的责任。

    1. void f(Cnt* p){
    2. p->inc();
    3. p->inc();
    4. }

    通过这种方式,也可以间接返回多个值。比如,返回点的坐标:

    1. bool f(Point* p1, Point* p2){
    2. if(p1->x < 0 || p1->y <0) return false;
    3. *p2 = *p1;
    4. p2->x++; p2->y++;
    5. return true;
    6. }

    逻辑上,p1 是输入参数,p2是输出参数

  • 传拷贝

    传拷贝的应用场合是,不希望函数的操作影响主调方。

    1. void f(Cnt x)
    2. {
    3. for(;x.get(); x.dec()){
    4. cout << "f..work..." << endl;
    5. }
    6. }
    7. int main()
    8. {
    9. Cnt a;
    10. a.inc();
    11. a.inc();
    12. f(a);
    13. cout << a.get() << endl;
    14. return 0;
    15. }
  • 传引用

    传引用,本质就是传指针,但可以使用对象而不是指针的而语法

    另外的好处是:

    引用不能运算,指针可以运算,

    1. void f(T& x){
    2. x++; // ????
    3. x[2] = ... //???
    4. }

总之,引用有了更多的限制,更安全。

顺便说:java中的指针,不叫指针,叫引用,道理即此。

  • 返回指针

    1. 传入的指针,再传回去,耗费资源很少,带来方便。。。。

      比如,链式操作。

    2. 动态申请的对象,返回地址,需要调用方去释放资源。

    主调方一般通过对称的函数来完成释放。

    1. Cnt* make_cnt()
    2. {
    3. // 可能根据配置文件,完成对象的动态创建
    4. return new Cnt();
    5. }
    6. bool free_cnt(Cnt* p)
    7. {
    8. delete p;
    9. }

    典型错误:

    返回局地对象的指针,使用方拿到时,此对象已作废。

    1. T* f(){
    2. T a;
    3. return &a; // 当对方收到这个指针时,a 已经死亡了。
    4. }
  • 返回引用

    最常见的用法是:踢皮球

    传入的参数,再传回去,一般就是为了链式调用。

    1. Cnt& inc() { x++; return *this; }

    则,调用时:

    1. a.inc().inc().inc();

实时效果反馈

1. 下列说法正确的是:__

  • A 形参为指针类型的目的是:修改主调方实参的指针值

  • B 形参为指针,在调用时不会产生创建新对象的动作

  • C 形参为对象引用,比形参为对象指针耗费更多的内存

  • D 形参为指针时,如果在函数内被置为NULL,会清空主调方的对象。

2. 如果,在一个函数中,返回了本地对象的指针,将会导致__

  • A 该对象无法被释放,导致内存泄漏

  • B 本地对象可以被释放,但不会调析构函数

  • C 主调方拿到指针时,本地对象已经销毁了,导致悬挂指针

  • D 主调方拿到的指针为NULL

答案

1=>B 2=>C

静态成员函数

image-20211228213747047

静态成员函数实际上接近于普通函数,只是拥有对对象的某些访问权限。

语法

函数用 static 修饰

调用时,不需要对象,直接 类名::静态成员函数

此时,类名的作用类似于:命名空间

逻辑上,静态成员函数,不封装到对象,但封装到类(外部可见性自己控制)

静态成员函数的常见使用场景

  1. 运算是对称的,如果使用 对象.成员函数的方式,则体现不出对称性

    仔细体会下面两个add函数语义上的微妙差异

    1. class Cnt{
    2. public:
    3. Cnt(){
    4. x = 0;
    5. }
    6. Cnt& inc(){ x++; return *this; }
    7. Cnt& dec(){ x--; return *this; }
    8. void show() { cout << x << endl; }
    9. void add(Cnt& t){ // t 的值并入 我自己
    10. x += t.x;
    11. }
    12. static Cnt add(Cnt& a, Cnt& b){ // a,b都不变,返回新的
    13. Cnt t;
    14. t.x = a.x + b.x;
    15. return t;
    16. }
    17. private:
    18. int x;
    19. };
  2. 第一个运算数不是对象,无法用 对象.成员函数的方式

    1. static Cnt add(int a, Cnt& b){
    2. Cnt a1;
    3. a1.x = a;
    4. return add(a1, b);
    5. }
  3. 作为与本类型相关工具函数,并不需要对象

    1. static void help(){
    2. cout << "_____ Cnt help ____" << endl;
    3. cout << " Cnt a_name; " << endl;
    4. cout << " a_name.inc(); // add one " << endl;
    5. cout << " ....... " << endl;
    6. }
  4. 其任务就是创建对象,现在流行称为:工厂方法

    当然在对象出生之前就可以调用。

    一般是根据配置文件,动态创建出对象,返回对象的指针。

    1. static Cnt* create(const char* txt){
    2. Cnt* p = new Cnt();
    3. sscanf(txt,"init=%d",&p->x);
    4. return p;
    5. }
    6. // 调用方:
    7. Cnt* p = Cnt::create("init=50");
    8. p->show();
    9. delete p;

静态成员函数的特征

没有自动传入的this指针

并不需要对象的存在,就可以被调用

实时效果反馈

1. 关于静态成员函数,说法==错误==的是:__

  • A 静态成员函数无法通过对象来调用。

  • B 静态成员函数的形参,并没有传入隐含的this指针

  • C 静态成员函数,可以通过类名限定来调用

  • D 静态成员函数,可以用来动态创建对象实例

2. 以下关于静态函数说法,==错误==的是__

  • A 静态成员函数比普通成员函数少了this指针

  • B 静态成员函数,可以在本类型的所有对象出生前调用

  • C 静态成员函数,可以作为本类型的工具函数,并不依存于对象

  • D 静态成员函数,可以用来释放对象申请的动态内存

答案

1=>A 2=>D

静态成员变量

章节二:信息学竞赛 CPP 对象篇 - 图20

静态成员变量是类级别的,不随着对象的出生而出生,所有对象出生前就存在了。

静态成员变量语法

定义时,加 static 修饰符

例如: static int x;

必须在外部进行初始化

例如: int T::x = 100;

这里,充分体现了类是蓝图,它不会耗费内存。

静态成员变量特性

  1. 对象未创建,就已经存在,可以用静态方法操纵它

  2. 与全局变量,静态变量类似,存在静态空间

  3. 只有一份,不跟随对象创建,不属于sizeof(对象) 大小

  4. 它可以被本类型所有对象共享。

    1. class T{
    2. public:
    3. int a; //跟随对象而生灭
    4. static int A; //只有一份,需要在外部创建并初始化
    5. };

    当,如下创建,内存图景:

    1. T a;
    2. T* p = new T();
    3. delete p; // 不要用free,那样不会调用析构

章节二:信息学竞赛 CPP 对象篇 - 图21

静态成员变量的典型用途

  1. 用于本类型对象的管理

    例如:检测内存泄漏。用一个 int 记录本类型对象现存的个数。

    1. class T{
    2. public:
    3. T(){ A++; }
    4. ~T(){ A--; }
    5. int x;
    6. static int A;
    7. };
  2. 设计模式中,著名的:单例模式

    有的时候,我们希望只有唯一的对象(比如,连接sqlite数据库的连接对象)

    这可以通过把构造函数私有化来实现。

    提供 static 函数来获得这个唯一对象。

    1. class T{
    2. public:
    3. static T* get_instance(){
    4. if(pObj==NULL) pObj = new T();
    5. return pObj;
    6. }
    7. static void free(){
    8. if(pObj){
    9. delete pObj;
    10. pObj = NULL;
    11. }
    12. }
    13. private:
    14. T(){}
    15. ~T(){}
    16. static T* pObj;
    17. };
    18. T* T::pObj = NULL; // 不要忘记真正创建变量
    19. int main()
    20. {
    21. T* p1 = T::get_instance();
    22. T* p2 = T::get_instance();
    23. cout << (p1 == p2) << endl;
    24. return 0;
    25. }

实时效果反馈

1. 关于静态成员变量,说法正确的是:__

  • A 静态成员变量可以在类内直接赋给初始值

  • B 静态成员变量必须在类外再次声明,以分配存储空间。

  • C 静态成员变量必须在静态成员函数中才能访问

  • D 静态成员变量在所有该类对象都析构后,自动释放内存。

2. 单例模式的作用是:__

  • A 确保所有使用者,使用的均是同一个对象。

  • B 保证对象在程序启动时,就已经创建好。

  • C 可以更快速地创建对象

  • D 可以让多个对象,通过同一个指针被外界使用。

答案

1=>B 2=>A

对象的状态

image-20211229212338042

对象是持有状态的实体

纯函数是:输入到输出的映射关系。普通函数=纯函数 + 副作用。

同一个类,创建的多个对象可以处于不同的状态。

成员函数的执行,与其关联的对象有关,即,该函数执行的上下文(contex)

饮料机a.购买(可乐,1)

饮料机b.购买(可乐, 1)

对象的状态迁移

外界对对象发出指令(向对象发送消息)

对象响应消息,改变自身的状态(这是与普通函数式思维的不同)

【实例】小机器人对象

image-20220103172920371

我们先设计一下,小机器人有哪里能力。

章节二:信息学竞赛 CPP 对象篇 - 图24

然后写出它的使用方式【测试驱动法TDD】

  1. Robot a;
  2. a.go(5);
  3. a.right();
  4. a.go(10);
  5. a.left().left().go(20); //需要支持链式
  6. a.show();
  7. a.reset();
  8. a.show();

最后,实现小机器人类:

  1. enum DIR{
  2. NORTH, EAST, SOUTH, WEST
  3. };
  4. struct Point{
  5. int x;
  6. int y;
  7. Point(){
  8. x = 0;
  9. y = 0;
  10. }
  11. void show(){
  12. printf("(%d,%d)", x, y);
  13. }
  14. };
  15. class Robot{
  16. public:
  17. Robot(){
  18. dir = NORTH;
  19. }
  20. Robot& go(int step){
  21. switch (dir) {
  22. case NORTH:
  23. pt.y += step;
  24. break;
  25. case EAST:
  26. pt.x += step;
  27. break;
  28. case SOUTH:
  29. pt.y -= step;
  30. break;
  31. case WEST:
  32. pt.x -= step;
  33. break;
  34. }
  35. return *this;
  36. }
  37. Robot& left(){ dir = (DIR)((dir-1+4)%4); return *this; }
  38. Robot& right(){ dir = (DIR)((dir+1)%4); return *this; }
  39. void show() {
  40. pt.show(); cout << " ";
  41. switch (dir) {
  42. case NORTH:
  43. cout << "north";
  44. break;
  45. case EAST:
  46. cout << "east";
  47. break;
  48. case SOUTH:
  49. cout << "south";
  50. break;
  51. case WEST:
  52. cout << "west";
  53. }
  54. cout << endl;
  55. }
  56. void reset(){
  57. pt.x = 0; pt.y = 0;
  58. dir = NORTH;
  59. }
  60. private:
  61. Point pt; //当前坐标
  62. DIR dir; // 朝向
  63. };

封装的含义

  • 封装是隔离

    隔离了功能和实现,隔离了外部特征和内部实现机制

  • 封装是关联

    关联了成员函数和对象状态,通过函数改变状态,函数服务于状态,不能独立存在

  • 封装是隐藏

    隐藏了数据,隐藏了结构,隐藏了实现细节

拓展:

只有一个小机器人对象,是寂寞的世界

如果,有多个小机器人

go 需要考虑碰撞的问题

可以相互协作完成任务

。。。。

让小机器人走迷宫,到岔路口,像孙悟空一样,复制出多个小机器人,走不同的路

两个撞了,就自动销毁一个。。。。。

实时效果反馈

1. 关于对象的封装性,说法正确的是:__

  • A 封装就是把对象加锁,避免同时访问

  • B 封装就是把对象保护起来,避免与外界交互

  • C 封装一般是把成员函数隐藏,把数据暴露出来

  • D 封装就是把结构和实现隐藏,把功能暴露出来

2. TDD在面向对象中的大体做法是:__

  • A 自顶向下的设计

  • B 螺旋上升的开发模型

  • C 先写对象如何使用,然后在需求的推动下,完善类的功能

  • D 先写出对象的数据,围绕着数据写它的功能

答案

1=>D 2=>C

对象的状态(2)

章节二:信息学竞赛 CPP 对象篇 - 图25

接上节,小机器人类实现细节的补充:

  1. TDD开发风格,先写如何用,再写如何实现

    ==需求推动==着我们去实现功能

    降低程序员的心智负担,只关注当前的问题

  2. TDD提倡==渐进式==的风格,不要一次写很多,改一点,测试一点

    通过不断反馈,不断完善,来接近最终目标

  3. 当条件允许时,优先采用可读性好的写法,易于维护

    程序的生命力在于它的可维护性,而不是正确性

  4. 注意枚举类型与整型的关系

    自动转换关系

  5. 重用性很重要,提供何种功能,有利于重用?有利于组合?

    功能单一性——高内聚

    联系简化 —— 低耦合

  6. 在面向对象的设计中,一般优先使用==组合模式==,其次才是继承模式

    组合,就是在对象数据中包含其它的对象(或指针,也有把这个称为==聚合==的)

实时效果反馈

1. 关于c++枚举类型与整型关系,说法正确的是:__

  • A 整型和枚举类型间可以自动相互转化

  • B 枚举类型可自动转整型,整型不能自动转枚举

  • C 整型可自动转枚举,枚举不能自动转整型

  • D 两者类型不同,不可自动转化

2. 为了提高软件单元的复用性,设计时,我们应该遵循__原则

  • A 高内聚,高耦合

  • B 高内聚,低耦合

  • C 低内聚,高耦合

  • D 低内聚,低耦合

答案

1=>B 2=>B

对象内存结构

image-20220103201820628

当没有继承,没有虚函数的时候,c++的对象内存模型相当简单

  1. 空对象,占用 1 字节

  2. 普通成员数据,跟随对象存储,属于sizeof(对象),可能涉及字对齐

  3. 静态成员数据,全局存储,静态空间,程序启动后确定下来

  4. 普通成员函数,静态成员函数,存储在代码区,不跟随对象

    逻辑上: 对象 = 数据 + 方法

    存储上: 对象 = 数据

    成员函数是障眼法,被翻译为普通函数,多一个隐含的参数 this 指针

    静态成员函数,没有this,因而等价于普通函数,只是编译器控制了访问权限

    不管对象创建多少,函数都只有一份拷贝

  1. 不管是: 对象.f() 指针->f() 还是 引用.f(),本质上都一样,

    调用f,传入一个指针,名字为 this,装着对象内存首地址

  2. 当对象的数据中含有其它对象时,累计其size;当含有任何类型指针,增加4字节

  3. 当对象的数据中,含有数组,则累计数组size,若含有数组地址,则只增4字节

    当有指针指向动态内存,只增4字节

    但,注意,一般在析构中要释放动态申请的内存。

示例

【Stu对象】包含:学号,姓名,分数。要求姓名采用动态存储法

  1. class Stu{
  2. public:
  3. Stu(int id, char* name){
  4. this->id = id;
  5. this->name = new char [strlen(name)+1];
  6. strcpy(this->name, name);
  7. score = 0;
  8. }
  9. ~Stu(){
  10. delete [] name;
  11. }
  12. void inc(int x=1){ score += x; }
  13. void dec(int x) { score -= x; }
  14. void show(){
  15. cout << id << ": " << name << "," << score << endl;
  16. }
  17. private:
  18. int id;
  19. char* name;
  20. int score;
  21. };

管理对象数组

对象数组是,包含对象本身的数组。其元素是对象。

如果需要创建对象数组,对象必须支持无参构造的方式。

如果不能接受对象的随机值,希望表达“无意义”这个概念。可以选用特殊值。

如果希望==批量初始化==对象,一般要需要一个 init 函数

通常,批量初始化的数据有某种连续特征,可以通过运算动态获得。

如果在栈中开数组,

会自动调用析构函数。

在堆中,则需要 delete [ ] p 来触发

管理对象指针数组

对象指针数组包含的是对象的指针,数组的元素是指针

也就是说,释放数组,并不会自动释放对象。

当管理对象时,我们需要考虑==两个==问题:

  1. 对象的内存释放了吗?
  2. 对象的析构函数调用了吗?

这是两个独立的问题,要小心!!!

实时效果反馈

1. 关于对象的存储,说法正确的是:__

  • A 对象的数据和操纵它的成员函数存在一起。

  • B 对象的内存包含了它的非静态数据和this指针。

  • C 当无继承时,对象的内存包含了它的所有非静态数据。

  • D 对象的内存总是动态分配的,其大小等于所有public数据大小之和

2. 关于对象指针数组,说法正确的是:__

  • A 在栈中分配的数组释放时,会自动调用每个指针的析构函数

  • B 在栈中分配数组,每个元素必须是同一类型对象的指针

  • C 本质就是指针数组,指针指向的内容由用户自行管理

  • D 数组可以自动分配,但,删除时需要调用 delete [ ] p;

答案

1=>C 2=>C

对象内存结构(2)

image-20220105144845397

接上节,对象及其动态内存的使用,有许多==著名大坑==

作为成熟的程序员,每个都应该踩过。

  1. 养成好习惯,delete 前判断NULL,删除后置为NULL
  2. 指向动态内存的指针被重新赋值,赋值前,问自己:释放了不?
  3. 只要不是构造函数,都可能被调用多次,一定要考虑好多次调用的后果。
  4. free 不是 delete,它会释放内存,但不调用析构
  5. delete 不是 delete [ ], 它会释放内存,会调用析构,但只调用 1 个。
  6. 养成好习惯,自己 new … , 一定检查是否有对一个的 delete
  7. 避免动态内存的最好方法之一:尽量不用它(用数组代替指针)
  1. class Stu{
  2. public:
  3. Stu(){
  4. id=-1;
  5. name[0] = '\0';
  6. score=-999;
  7. }
  8. Stu(int id, char* name):Stu(){
  9. init(id, name, 0);
  10. }
  11. ~Stu(){
  12. cout << "destroy...stu.." << id << endl;
  13. }
  14. void init(int id, char* name, int score){
  15. this->id = id;
  16. strncpy(this->name, name, 25);
  17. this->score = score;
  18. }
  19. void inc(int x=1){ score += x; }
  20. void dec(int x) { score -= x; }
  21. void show(){
  22. cout << id << ": " << name << "," << score << endl;
  23. }
  24. private:
  25. int id;
  26. char name[30];
  27. int score;
  28. };

实时效果反馈

1. 对成员函数的指针数据重新赋值, 可能引起什么不良后果:__

  • A 悬挂指针

  • B 内存泄漏

  • C 忘记调析构函数

  • D 触发垃圾回收

2. 成员指针 delete 后,不置为NULL,有什么隐患?__

  • A 悬挂指针

  • B 内存泄漏

  • C 栈溢出

  • D 数组访问越界

答案

1=>B 2=>A

拷贝构造

章节二:信息学竞赛 CPP 对象篇 - 图28

拷贝构造就是对象克隆

一个对象,可能有各种各样的出生方式。

  1. 指定合适的参数出生(最容易识别)

  2. 无参默认出生

    有时,是隐式的:

    比如,对象数组

    比如,在一个对象中包含了另一个对象

  3. 拷贝另一个对象而出生

    有时,很隐蔽。

    比如:对象实参传递给形参;函数返回一个对象

  4. 由赋值语句而出生

    把一个对象赋值给另一个对象

我们来监测对象的出生和销毁

  1. class A{
  2. public:
  3. A(){ cout << "default create" << endl; }
  4. A(A& x){ cout << "copy create" << endl; }
  5. ~A(){ cout << "destroy " << endl; }
  6. };

注意:

拷贝构造的形参,一定是引用类型,否则本身就会引发拷贝构造。

在其它场景,同样使用引用可以避免拷贝的发生

  1. A a;
  2. A b = a; // 拷贝构造
  3. A c(a); // 拷贝构造
  4. b = c; // 这是对象赋值,并没有新的对象出生,因而无所谓:“构造”

函数中传递的情况:

  1. A f(A x){ return x; }
  2. // ..... 调用处:
  3. A a;
  4. f(a);

如果不定义拷贝构造

系统执行默认的拷贝行为,可能不是我们期望的。

可以理解为:内存级别的“照相”复制

即是: memcpy(&b, &a, sizeof(a));

这对简单对象,完全正确,比如: Point, Cnt 等

如果对象内有指针就要当心了,会造成:指针牵连

浅拷贝与深拷贝

系统默认的拷贝构造行为是:浅拷贝。

如果需要深拷贝,则自己定制。

我们前面讲过:浅拷贝,深拷贝的话题。其内容与对象拷贝完全类似。

甚至,如果我们已经写好了 copy 函数,可以在拷贝构造中,直接调用它。

下面给出链表对象的拷贝构造例子:

队列对象

  1. struct Node{
  2. int data;
  3. Node* next;
  4. Node(int x){
  5. data = x;
  6. next = NULL;
  7. }
  8. };
  9. class Que{
  10. public:
  11. Que() { head=NULL; tail=NULL; }
  12. ~Que() { int x; while(pop(x)); }
  13. Que& push(int x){
  14. Node* t = new Node(x);
  15. if(tail){
  16. tail->next = t;
  17. tail = t;
  18. }else{
  19. tail = t;
  20. head = t;
  21. }
  22. return *this;
  23. }
  24. bool pop(int& x){
  25. if(head==NULL) return false;
  26. Node* t = head;
  27. if(head==tail){
  28. head = NULL;
  29. tail = NULL;
  30. }else{
  31. head = t->next;
  32. }
  33. x = t->data;
  34. delete t;
  35. return true;
  36. }
  37. private:
  38. Node* head;
  39. Node* tail;
  40. };

基本用法:

  1. Que a;
  2. a.push(10).push(20).push(30);
  3. int x;
  4. while(a.pop(x)) cout << x << endl;

但,有个致命隐患,当拷贝队列对象的时候。。。。。

image-20220106090731304

定义拷贝构造来复制整个队列:

实现起来也不难,关键是充分利用已经有的基础设施

  1. Que(Que& t):Que(){
  2. Node* p = t.head;
  3. while(p){
  4. push(p->data);
  5. p=p->next;
  6. }
  7. }

实时效果反馈

1. 如果A是类,执行 A a; A b; b=a; 说法正确的是:__

  • A 调用 2 次默认构造,1 次拷贝构造

  • B 调用 2 次默认构造

  • C 调用 1 次默认构造,1 次拷贝构造

  • D 与编译环境有关

2. 下列哪种行为肯定==不会==引发拷贝构造__

  • A 调用形参为对象的函数

  • B 用一个对象来初始化另一个局地对象

  • C 从函数返回一个对象类型

  • D 从函数返回一个对象引用类型

答案

1=>B 2=>D

赋值函数

章节二:信息学竞赛 CPP 对象篇 - 图30

前边的课程中,我们知道:

运算符的本质就是函数,参数就是运算数

= 是运算符,b = a 的默认动作是:把 a 的值拷贝给 b,同时返回 b 的值

当,a b 都是对象时,复杂了。。。。

默认的赋值行为

b = a; 把 a 对象的内存“照相”拷贝到 b,返回b对象的引用。

当简单对象时,这种行为是正确的。

当对象复杂时,比如,含有指针,产生牵连(与拷贝构造类似)

除此之外,还有更严重的错误:

image-20220106111231204

上一节的 Que 类

  1. // 链表的节点
  2. struct Node{
  3. int data;
  4. Node* next;
  5. Node(int x){
  6. data = x;
  7. next = NULL;
  8. }
  9. };
  10. //先进先出的队列
  11. class Que{
  12. public:
  13. Que() { head=NULL; tail=NULL; }
  14. Que(Que& t):Que(){ //保证在拷贝构造前,head,tail为NULL
  15. Node* p = t.head;
  16. while(p){
  17. push(p->data);
  18. p=p->next;
  19. }
  20. }
  21. ~Que() { int x; while(pop(x)); }
  22. Que& push(int x){ //返回Que& 为了链式操作
  23. Node* t = new Node(x);
  24. if(tail){
  25. tail->next = t;
  26. tail = t;
  27. }else{
  28. tail = t;
  29. head = t;
  30. }
  31. return *this;
  32. }
  33. bool pop(int& x){ //不设计为直接返回 int,因为有队列空的情况
  34. if(head==NULL) return false;
  35. Node* t = head;
  36. if(head==tail){
  37. head = NULL;
  38. tail = NULL;
  39. }else{
  40. head = t->next;
  41. }
  42. x = t->data;
  43. delete t;
  44. return true;
  45. }
  46. private:
  47. Node* head; //指向对头,从这里弹出数据
  48. Node* tail; //指向队尾,在其后压入数据
  49. };

赋值函数语法

  1. ~Que() { clear(); }
  2. void clear(){ int x; while(pop(x)); }
  3. Que& operator=(Que& t){
  4. clear();
  5. Node* p = t.head;
  6. while(p){
  7. push(p->data);
  8. p=p->next;
  9. }
  10. return *this;
  11. }

实时效果反馈

1. operator= 运算符重载,为什么要返回引用类型__

  • A 为了支持连续赋值,形如: c = b = a;

  • B 语法规定,否则编译不过

  • C 为了和形参一致

  • D 为了兼容 c 语言

2. 与默认的拷贝构造行为相比,默认的对象赋值行为,可能增加的危害是:__

  • A 对象牵连

  • B 悬挂指针

  • C 内存泄漏

  • D 栈溢出

答案

1=>A 2=>C

友元函数

image-20220103204442059

一个规范的类,

应该至少满足:封装性,即是,它把内部的数据和实现机制隐藏起来。

但,有的时候不方便,

比如,这个类可能需要向调试、测试、诊断等工具放开权限

类比现实世界,

每个人都有隐私,但,我们不应该对医生或牧师隐瞒。

c++,为了高效率解决这个冲突,引入了友元函数

什么是友元函数?

一句话来概括:友元函数就是一个对某个类有特权的普通函数

谁来授权?

类对该函数授权,加 friend 修饰符。

要点:

  1. 友元函数只是外部的普通函数,并非本类成员函数,因而:
  2. 它没有隐藏的 this 指针。
  3. 如果需要它操纵对象(读取或改写),必须显式地传入对象。
  4. 它不需要this,当然,可以使用类的静态成员函数或静态成员数据。

真实的例子(月结打卡类Punch)

  1. class Punch{
  2. public:
  3. Punch(){ data = 0; }
  4. void set(int day){
  5. if(day<1 || day>31) return;
  6. data |= 1 << (day-1);
  7. }
  8. bool get(int day){
  9. if(day<1 || day>31) return false;
  10. return data & (1 << (day-1));
  11. }
  12. private:
  13. unsigned int data; // 按二进制位记录每天打卡情况
  14. };

使用方法:

  1. Punch a;
  2. a.set(5);
  3. a.set(8);
  4. cout << a.get(2) << endl;
  5. cout << a.get(5) << endl;

某诊断函数需要输出 data 的真实值

  1. void debug_punch(Punch& x){
  2. cout << "puch.data:" << hex << x.data << endl;
  3. }

在Punch类中,对这个普通函数放开private权限

  1. friend void debug_punch(Punch& x);

测试一下:

  1. Punch a;
  2. a.set(5).set(10).set(12);
  3. cout << a.get(2) << endl;
  4. cout << a.get(5) << endl;
  5. debug_punch(a);

实时效果反馈

1. 关于友元函数, 说法正确的是:__

  • A 友元函数是特殊的成员函数,一般用来输出诊断信息

  • B 友元函数就是类的静态成员函数,没有 this 指针。

  • C 友元函数是普通函数,获得了类的特殊访问授权。

  • D 友元函数是c++的特殊函数,专门为了调试而设计的。

2. 友元函数与this指针的关系:__

  • A 有隐含的 this 指针,但只能读,不能写。

  • B 没有隐含 this 指针,可以访问类的静态数据

  • C 有隐含 this 指针,但无法访问类的静态数据

  • D 没有隐含的 this 指针,也无法访问静态数据

答案

1=>C 2=>B

友元类

image-20220103205026284

为了体现封装性,把对象的私有数据隐藏是很棒的实践!

但,有些场合,效率是很重要的,

我们希望,某些我们信任的类,可以操作我的 private 成员。

授权声明

友元函数类似,可以向某个授权,该类的方法可以突破本类的private限制。

注意,这个授权是单向的。

B 是 A 的友元类,并不一定:A 是 B 的友元类。

如果需要,则双向都要授权才行。

真实案例

重新审视,在前边做过的Que类,

  1. Que a; a.push(10).push(20).push(30);
  2. Que b; b.push(33).push(99);
  3. b = a;
  4. int x; b.pop(x); b.pop(x);
  5. while(a.pop(x)) cout << x << endl;

属于傻瓜式用法,用户无法操纵Node对象,内存完全由Que管理。

在复杂场合有效率问题。

比如,从一个队列 b 中拿来一个元素,放入另一个队列 a 中。

可不可以,让用户管理 Node 呢?

  1. Que a;
  2. a.push(new Node(10));
  3. a.push(new Node(20));
  4. a.push(new Node(30));
  5. Que b;
  6. b.push(new Node(100));
  7. b.push(new Node(200));
  8. delete b.pop(); //从队列出来的是 Node* 类型,要么删掉
  9. a.push(b.pop()); // 要么,挂接到别处
  10. a.show(); cout << endl;

Que的设计:

  1. class Que{
  2. public:
  3. Que() { head=NULL; tail=NULL; }
  4. Que(Que& t):Que(){ copy(t); }
  5. ~Que() { clear(); }
  6. void clear(){ Node* p; while(p=pop()){ delete p; } }
  7. void copy(Que& t){
  8. for(Node* p=head; p; p=p->next) push(p->copy());
  9. }
  10. Que& operator=(Que& t){
  11. clear();
  12. copy(t);
  13. return *this;
  14. }
  15. Que& push(Node* p){
  16. if(tail){
  17. tail->next = p;
  18. tail = p;
  19. }else{
  20. tail = p;
  21. head = p;
  22. }
  23. return *this;
  24. }
  25. Node* pop(){
  26. if(head==NULL) return NULL;
  27. Node* t = head;
  28. head = t->next; t->next = NULL;
  29. if(head==NULL) tail = NULL;
  30. return t;
  31. }
  32. void show(){
  33. for(Node* p=head; p; p=p->next){
  34. p->show(); cout << " ";
  35. }
  36. }
  37. private:
  38. Node* head;
  39. Node* tail;
  40. };

限制 Node 的 public 功能

  1. class Node{
  2. public:
  3. Node(int x){ data = x; next = NULL; }
  4. Node* copy(){ return new Node(data); }
  5. void show(){ cout << data; }
  6. private:
  7. int data;
  8. Node* next;
  9. friend class Que;
  10. };

实时效果反馈

1. 关于友元类, 说法正确的是:__

  • A 如果 A 是 B 的友元类,则,B 也是 A 的友元类

  • B A 和 B 不能相互是对方的友元类

  • C 我的友元类不能访问我的 static 成员

  • D 友元类授权等价于,对该类的所有函数,进行友元函数授权

答案

1=>D

内部类

image-20220103210258744

内部类,也有称为:嵌套类

就是把一个类定义在另一个类的内部。

  1. class A{
  2. ....
  3. class B{
  4. };
  5. ....
  6. };

需要注意:

c++的内部类与 java 不同。

没有复杂的外部对象 this 引用,依存关系等等。

c++的内部类与其外部类间联系更弱,除了名字空间的限定外,几乎就是两个独立的类。

如果说便利条件,有一点点:

内部类可以访问外部类的 private 成员,不用开通友元。

何时需要内部类?

考虑这样的情景,

我们的多个数据结构:

比如说,我们定义了:栈,队列,链表,双向链表等都有 Node 概念。

一种方法是:XXX_Node,通过类名字来区分。

还有一种,把Node定义挪到主类的内部,这样,访问时:

Que::Node, Link::Node, Stack::Node 等等来区分

这里,外部类起到了 名字空间 的作用。

  1. class Que{
  2. public:
  3. class Node{
  4. public:
  5. Node(int x){ data = x; next = NULL; }
  6. Node* copy(){ return new Node(data); }
  7. void show(){ cout << data; }
  8. private:
  9. int data;
  10. Node* next;
  11. friend class Que;
  12. };
  13. public:
  14. Que() { head=NULL; tail=NULL; }
  15. Que(Que& t):Que(){ copy(t); }
  16. ~Que() { clear(); }
  17. void clear(){ Node* p; while(p=pop()){ delete p; } }
  18. void copy(Que& t){
  19. for(Node* p=head; p; p=p->next) push(p->copy());
  20. }
  21. Que& operator=(Que& t){
  22. clear();
  23. copy(t);
  24. return *this;
  25. }
  26. Que& push(Node* p){
  27. if(tail){
  28. tail->next = p;
  29. tail = p;
  30. }else{
  31. tail = p;
  32. head = p;
  33. }
  34. return *this;
  35. }
  36. Node* pop(){
  37. if(head==NULL) return NULL;
  38. Node* t = head;
  39. head = t->next; t->next = NULL;
  40. if(head==NULL) tail = NULL;
  41. return t;
  42. }
  43. void show(){
  44. for(Node* p=head; p; p=p->next){
  45. p->show(); cout << " ";
  46. }
  47. }
  48. private:
  49. Node* head;
  50. Node* tail;
  51. };

friend 修饰符还是需要的。

如果内部类访问外部类的 private 成员,则不需要授权。

需要再次强调:

不管是内部类访问外部类,还是外部类访问内部类,

都需要先获得被操作对象(或指针,或引用)

没有默认的 this 指针等内部联系。

实时效果反馈

1. 关于内部类, 说法正确的是:__

  • A 要创建内部类对象,必须先创建其外部类对象

  • B 要创建外部类对象,必须先创建其所有内部类对象

  • C 创建外部类对象的同时,隐含创建了内部类对象

  • D 外部类对象,内部类对象都可以独立创建或不创建

2. 下列关于访问权限,说法正确的是:__

  • A public 的内部类,可以在外部类以外创建对象,直接用内部类名。

  • B public 的内部类,可以在外部类以外创建对象,用 外部类::内部类限定

  • C private 的内部类,在外部类以外,也可以创建内部类对象

  • D private 的内部类,在外部类中,也无法创建它的对象。

答案

1=>D 2=>B

运算符重载

image-20220103210732468

函数可以重载,即:函数名相同,但参数类型不同或个数不同。

运算符是变相的函数,因而可以重载。

有些场合,运算符比函数的表达更直观。尤其,引入了对象。

比如两个矩阵 A 和 B 相加,C = A.add(B),就不如: C = A + B 更简明,直观。

为什么要重载运算符?

为了表达上的直观,简洁,清晰,

否则,我们只好定义对应的函数。

如何重载运算符?

可以重载为成员函数,也可以重载为普通函数。

比如, T a, b;

如何支持: a + b ?

可以定义普通函数: T operator+(T& x, T& y);

也可以定义 T 的成员函数: T operator+(T& x);

这二者的本质是一样的,因为,成员函数有个隐藏的 this 指针,所以也是双目运算

返回值类型并没有规定,但最好遵循惯例。

如何支持: a + 2 ?

可以定义普通函数: T operator+(T& x, int y);

也可以定义 T 的成员函数: T operator+(int x);

如何支持: 2 + a ?

这就只能定义普通函数的了:T operator+(int x, T& y);

常见的重载运算符

类别 目数 运算符
算数运算符 2 + - * / %(加减乘除,取模)
算数运算符 1 +(正) -(负)++(自增) —(自减)
指针与地址 1 *(取对象) &(取地址)
关系运算 2 == != > < >= <=
逻辑运算 2 && \ \ !(与或非)
位运算 2,1 \ & ~ ^ << >>
赋值 2 = += -= *= /= &= \ = ^= <<= >>=

应用实例

还以前边做过的计数器类为例子:

如果,我们希望这样使用它:

  1. Cnt a; a.inc(); a.inc();
  2. Cnt b; b.inc();
  3. Cnt c = 100 + a + b + 10;
  4. cout << c.get() << endl;
  5. cout << c << endl;

这里,需要重载哪些运算符呢? 哪些是必须重载为友元函数的呢?

  1. class Cnt{
  2. public:
  3. Cnt(){ x=0; }
  4. Cnt& inc() { x++; return *this; }
  5. Cnt& dec() { x--; return *this; }
  6. int get() { return x; }
  7. Cnt operator+(Cnt& t){ return Cnt(x+t.x); }
  8. Cnt operator+(int t){return Cnt(x+t); }
  9. private:
  10. Cnt(int a){ x = a; }
  11. int x;
  12. friend Cnt operator+(int, Cnt&);
  13. friend ostream& operator<<(ostream& os, Cnt&);
  14. };
  15. Cnt operator+(int a, Cnt& b) { return Cnt(a + b.x); }
  16. ostream& operator<<(ostream& os, Cnt& t)
  17. { os << t.x; return os; }

实时效果反馈

1. 运算符重载时,什么情况下,必须要重载为友元函数?__

  • A 第 1 个参数非自定义类的对象(或对象引用)时。

  • B 第 2 个参数非自定义类的对象(或对象引用)时。

  • C 单目运算符时

  • D 三目运算符时

2. 为了实现 cout << 自定义对象功能,应该怎么办? __

  • A 重载自定义类的成员函数 operator<< (ostream&)

  • B 重载友元函数 ostream& operator<<(ostream&, 自定义类&)

  • C 重载友元函数 istream& operator<<(ostream&, 自定义类&)

  • D 重载友元函数 ostream& operator<<(自定义类&,ostream&)

答案

1=>A 2=>B

重载运算符基本规则

image-20220108130410603

  1. 为了防止用户把系统的标准运算改乱,比如: 1 + 2,

    规定:运算数至少有一个用户自己定义的类型。

  2. 不能改变运算符原来的目数,比如: %myObj,不可以

    % 是双目运算符。(但,有些运算符可单可双)

  3. 不能改变原来的优先级和结合律

    a+b*c 无论怎么重载,还是先算 b * c,结果再加法

  4. 不能自创一个运算符,很多人有重载 的冲动。希望 myObj 2 表示平方,不可以。

    a => b 也不行,不存在这个运算符

    有些高级语言有: a <=> b, 返回值是 -1,0,1, 确实很优美,但c++不可。

  5. 不能改变原来的操作数位置(主要指单目) a! a~ 都是不可以的

    它们原来的用法都是前置运算符。

  6. 并非所有 c++运算符都可以重载

    比如:.(成员运算)::(域运算符)sizeof (取长度) ?:(条件运算)

  7. 不能给参数设置默认值。

    那样就很难区分目数,容易引起歧义。

  8. 特殊的运算符,如:( )、[ ]、->、=,重载时必须将声明为成员函数,而不能声明为友元函数。

    这实际上是要求,左边需要用户定义类型,而不能是系统类型。

    这个需求很明显:

    x[5] 的行为可以重新解释;

    但,5[x] 就太怪异了,因而要禁止!

c++的运算符重载,本着实用主义的宗旨:

没有它也可以完成任务,

有了它,绝不能影响效率或者引发混乱。所以,其几乎没有专门设计,

功能较弱,也不完善。

踩坑

如果重载了 && || 运算符,会发现,它的短路求值功能不见了。

为了便于输出,cout << 自己类型,重载注意,不会改变优先级。

cout << a & b << endl; 必然是等价于: (cout << a) & (b << endl);

如果拿不定注意,就多加括号吧!

真正理解 ++, — 的机会

我们在前边课程中讲过:

a++,++a 的区别:

b = a++, 先执行++,后执行 = (不是: 先赋值,再++)

a++ 运算的副作用是:改变了 a 的值,运算的结果:返回了a 改变前的值。

我们把这个东西套用到 Cnt 类,来体验一下:

  1. class Cnt{
  2. public:
  3. Cnt(){ x=0; }
  4. Cnt& inc() { x++; return *this; }
  5. Cnt& dec() { x--; return *this; }
  6. int get() { return x; }
  7. Cnt& operator++(){ x++; return *this; }
  8. Cnt operator++(int){ Cnt t = *this; x++; return t; }
  9. // 注意这个返回值类型,不能用引用形式。
  10. private:
  11. Cnt(int a){ x = a; }
  12. int x;
  13. };

实时效果反馈

1. 关于运算符重载, 说法正确的是:__

  • A 不能改变原来的优先级和结合律。

  • B 可以改变原来的目数

  • C 可以定义系统原来没有的运算符

  • D 双目运算符都可以重载为友元函数

2. 不能重载的运算符是:__

  • A ->

  • B &

  • C .

  • D [ ]

答案

1=>A 2=>C

特殊运算符的重载

image-20220108131401286

( )、[ ]、->、= 比较特殊,必须重载为成员函数,

即:第一个参数必须是用户定义类型

=运算符

这个其实就是,前边讲过的对象赋值

但,注意:

  1. 它未必是两个同类型对象间的赋值,也可以任意类型对本类型赋值。

    1. T a,b;
    2. a = b; // T& operator=(T& t);
    3. a = 5; // T& operator=(int t);
  1. 未必要有返回值,可以是 void,那就不能支持连等赋值格式。

  2. 返回值未必是本类型引用,返回对象也可以,甚至任何类型都可以。

  3. 如果对象的成员数据有指针,默认的赋值操作可能有害:

    i. 造成对象牵连

    ii. 造成内存泄漏

章节二:信息学竞赛 CPP 对象篇 - 图38

[ ] 运算符

这个本意是数组取下标操作。不过,我们可以改为任何含义。

它既可以是左值,也可以是右值

  1. T a;
  2. a[1] = 5; // 用为左值,这是两个运算符:
  3. // []一定要返回引用,才能支持赋值
  4. b = a[1]; // 用为右值

如果不支持左值,可以返回任何需要的类型

  1. class Punch{
  2. public:
  3. Punch(){ data = 0; }
  4. void set(int day){
  5. if(day<1 || day>31) return;
  6. data |= 1 << (day-1);
  7. }
  8. bool get(int day){
  9. if(day<1 || day>31) return false;
  10. return data & (1 << (day-1));
  11. }
  12. bool operator[](int day){ return get(day); }
  13. private:
  14. unsigned int data; // 按二进制位记录每天打卡情况
  15. };

[ ]用法,与 get() 是类似的(本质上是同一的)

  1. Punch a;
  2. a.set(5);
  3. cout << a[5] << endl;

( )运算符

本意是函数运算,可以改为任意含义。

既可以左值,也可右值,所以一般返回引用类型。

参数的个数随便,也可以没有。

比如,常见的矩阵取值,赋值操作:

  1. T a(3,4); //矩阵类型 3 行, 4 列
  2. a(1,1) = 5; // 写入
  3. cout << a(1,1) << endl; // 读取

-> 运算符

本意是对象的指针访问成员函数或数据。

可以重载,应用于对象,而不是指针,使得对象的行为看起来像指针。

对象伪装为指针。

目的:在恰当的时候,强迫回收资源,典型实现:智能指针。

一般配合 * 运算符重载联合使用。

  1. // 用户类型
  2. struct T{
  3. void f() { cout << "T::f()..." << endl; }
  4. };
  5. // 服务于 T 的智能指针
  6. class PT{
  7. public:
  8. PT(){ p = new T; }
  9. ~PT() {
  10. cout << "free T ..." << endl;
  11. delete p;
  12. }
  13. T* operator->(){ return p; }
  14. private:
  15. T* p;
  16. };

可以比较和普通指针的操作:

  1. T* p1 = new T;
  2. p1->f(); // p1 对内存泄漏无能为力,它没有机会...
  3. PT p2; // p2 是对象,伪装为 T* 类型,但又析构的机会....
  4. p2->f();

实时效果反馈

1. 一般在什么情况下,需要重载对象赋值运算:__

  • A 对象中含有数组数据成员

  • B 对象中含有嵌套对象

  • C 对象中含有内部类

  • D 对象中含有指针数据成员

2. 重载 ->运算符 的场景,比较典型的应用是:__

  • A 避免空指针

  • B 避免对象赋值

  • C 希望指针变量生命期结束时,获得处理机会。

  • D 避免对象连体现象(指针牵连)

答案

1=>D 2=>C

const修饰符

image-20220108131820117

const 的含义是:定常的,不变的。

它修饰的变量,可以避免无意或有意的修改。

代替宏的好处

程序中常量,用const,可以和宏定义一样,避免魔法数字。

比宏的优势在:

  1. const 有类型,编译器能介入更多的监督。
  2. const 定义的变量只有一处存储位置,而宏定义是替换,可能有多份拷贝。
  3. 在有些优化下,编译器根本不为const 真正分配内存,只作为编译立即数。

指针类型相关

const T p; T const p; 修饰被指向的内容

T * const p; 修饰的是指针本身

const T * const p; 指针和指向的内容都不可改变

函数相关

  1. 形参,修饰普通类型:

    void f(const int x) 无意义,因为 x 变化对主调方无影响

  2. 形参,修饰指针类型:

    void f(const T* p) 无法通过形参指针影响实参对象

  3. 形参,修饰引用类型:

    void f(const T& p) 与指针类型的情况一样

  4. 返回值

    很少用 const,

    当返回对象引用时,可防止接下来的链式操作,更改对象状态。

对象相关

  1. 修饰成员变量

    则为常量,无法赋值,只能在初始化列表里初始化

    1. public:
    2. T():x(100){} // 不允许出现 x = ...
    3. private:
    4. const int x;
  2. 修饰成员函数

    1. void f() const { cout << y << endl; }

    表示该函数不会修改对象的状态

    当然,编译器不会允许它改状态的行为。

    也不允许,调用其它非const修饰的成员函数

  3. 修饰对象(或者对象指针,对象引用)

    该对象上只能执行 const 函数,或者读的动作,不能改写状态

    (禁止的不一定是行为,有企图,或者说可能,就要禁止)

翻墙

const_cast<类型>( xxx ) 进行强制转换

  1. const T a;
  2. a.g(); // g() 非const函数,不可调用
  3. const_cast<T&>(a).g(); //强制后,去掉了const约束

实时效果反馈

1. T是类,对 const T a; 说法正确的是:__

  • A T b; 则 a=b; b=a; 都是允许的

  • B 不允许调用 a 中的静态成员函数

  • C 不允许调用 a 中的非const 成员函数

  • D 不允许读取 a 中的数据成员

2. 用 const 修饰对象(或其指针,或其引用)的目的是:__

  • A 防止对象的状态发生改变

  • B 防止对象被拷贝

  • C 防止对象内存泄漏

  • D 防止对象内存被释放

答案

1=>C 2=>A

成员对象与封闭类

image-20220108133349297

一个对象的成员数据如果是对象,则该对象称为成员对象

此时,包含成员对象的这个类,称为封闭类(enclosed class)

是指外围那个类,不是被包含的那个

封闭类的构造函数

包含成员对象的类,初始化时有特殊性。

在进入初始化函数之前,它所内嵌的对象必须已经初始化完成。

那么,如果去初始化它呢?显然是通过该对象的构造函数完成。

如果该对象的类有多个构造函数,调哪个能控制不?

能! 使用 初始化列表

  1. class A{
  2. public:
  3. A(){ cout << "A()" << endl; }
  4. A(int a) { cout << "A(a)" << endl; this->a = a; }
  5. private:
  6. int a;
  7. };
  8. // enclosed class, init-table may be used
  9. class B{
  10. public:
  11. B(int a, int b):x(a){ y = b; }
  12. private:
  13. A x;
  14. int y;
  15. };

初始化列表很有用,对普通类型也可以用初始化列表来初始化。

另一个必须使用的场景是:当有继承时,对父类的构造方法进行选择。

综合示例-智能指针

  1. class T{
  2. public:
  3. class P{
  4. public:
  5. P(T* p):p(p){}
  6. ~P(){ if(p){delete p; p=NULL;} }
  7. T* operator->(){ return p; }
  8. private:
  9. T* p;
  10. };
  11. T(){ cout << "T() .. " << endl; }
  12. ~T() { cout << "~T() .. " << endl; }
  13. void f() { cout << "T::f() .. " << endl; }
  14. };

T 是普通的类,但附带了一个仿真指针的内部类型 P

P 可以代替 T* 来完成各种操作

获得的额外好处是:可以自动调用析构,防止忘记释放 new T 分配的内存。

  1. T::P p = new T();
  2. p->f();

这里要分辨概念:

内部类,成员指针,成员对象,初始化列表

考虑在链表的 Node 中,使用 T::P 来代替 T*

通用的Node类型:

  1. struct Node{
  2. T* data;
  3. Node* next;
  4. }; // 好处是,不管用户数据类型是什么,此结构总是8字节

这样,对 T* 指向内存的管理十分危险,很容易泄漏。

使用智能指针代替:

  1. struct Node{
  2. T::P data;
  3. Node* next;
  4. };

此时,Node 仍然是 8 字节,但

data 不再是普通指针,而是一个对象。如果它销毁,会触发析构

此时,编译无法通过,因为虽然 Node 支持默认无参构造,但 data 不支持

所以,必须用初始化列表:

  1. struct Node{
  2. Node(T* t):data(t){next=NULL;}
  3. T::P data;
  4. Node* next;
  5. };

测试:

  1. Node a(new T());
  2. Node b(new T());
  3. a.next = &b;

实时效果反馈

1. 关于封闭类, 说法正确的是:__

  • A 封闭类就是内部类

  • B 封闭类就是包含内部类的外部类

  • C 封闭类就是,包含其它类对象作为数据成员的外部类

  • D 封闭类就是被其它类包含的类的对象

2. 哪个==不是==初始化列表的作用:__

  • A 封闭类的构造函数执行之前,先要初始化内嵌对象。

  • B 本类构造函数执行之前,先要执行父类的构造。

  • C 对本类的普通数据,也可以在初始化列表里指定初值。

  • D 为本类的静态数据指定初值

答案

1=>C 2=>D

智能指针之引用计数

image-20220115210654800

我们用对象真能冒充指针吗?

换句话说,能冒充指针的所有操作,而不露出马脚吗?

这没有绝对,取决于我们应用的场景需要哪些功能。

比如,要不要支持 p++, 要不要支持 *p。

这些是明面上的功能,还有隐形的需求:

其中,最重要的是:如何处理多个指针指向同一对象?

  1. T::P p(new T()); // 构造
  2. p->f();
  3. T::P q = p; // 拷贝构造
  4. T::P s(new T());
  5. s = q; // 对象赋值

内存图景如下:

image-20220115205151606

可以使用著名的引用计数方案

就是在被指向对象中存放一个计数

每当有智能指针指向它,就加 1

每当有智能指针移开它,就减 1

当计数为 0, 释放对象内存,触发调用析构。。。。

  1. class T{
  2. public:
  3. class P{
  4. public:
  5. P(T* p):p(p){ p->n++; }
  6. P(P& t){ copy(t); }
  7. ~P(){ clear(); }
  8. void clear(){
  9. p->n--;
  10. if(p->n == 0) { delete p; p=NULL; }
  11. }
  12. void operator=(P& t){ clear(); copy(t); }
  13. T* operator->(){ return p; }
  14. private:
  15. void copy(P& t){ p = t.p; p->n++; }
  16. T* p;
  17. };
  18. T(){ cout << "T() .. " << endl; n=0; }
  19. ~T() { cout << "~T() .. " << endl; }
  20. void f() { cout << "T::f() .. " << endl; }
  21. private:
  22. int n; // 本对象的引用计数
  23. };

微软的COM组件广泛使用了这个方案(当然,要复杂得多)

在有多线程,多进程的情况下,形势还要更严峻。

com内存泄漏也是十分棘手。。。

注意一个语法现象:

在类 P 中,使用了 T 中的私有数据成员(计数变量),

但,T 并未对其开放友元类权限。

这是内部类的访问特权,其原因很容易理解:

既然,把一个类包含在本类定义中,十有八九这两个类是同一个程序员维护的。

语法只有在具体、真实的使用场景中,才能显示它的朴实无华,简洁高效。

实时效果反馈

1. 智能指针,如何支持 *p 运算__

  • A 重载 * 运算符,返回目标对象的引用类型

  • B 重载 * 运算符,返回目标对象类型

  • C 重载 * 运算符,返回 this

  • D 重载 运算符,返回 this

2. 当在智能指针需要依赖引用计数时,该计数变量放在哪里合适?__

  • A 作为智能指针对象的成员

  • B 作为智能指针类的静态成员

  • C 作为智能指针指向的目标对象的成员

  • D 作为智能指针指向目标类的静态成员

答案

1=>A 2=>C

日期类型

image-20220112194746525

日期,一般表述为某年某月某日。可以把这些数据捆绑为一个对象。

日期的常见运算是:

  1. 求两个日期差多少天
  2. 求某个日期 + x天 (或 - x天)后的日期
  3. 求某个日期是星期几

TDD,首先,写出将要如何使用它

  1. Date a(2000,12,31);
  2. cout << a << endl;
  3. Date b(2001,1,1);
  4. cout << b << endl;
  5. cout << (b-a) << endl; // 求日期天数差
  6. cout << Date(2022,1,15).week_day() << endl; //求星期几

设计实现思路

如何进行两个日期的求差?

一种想法,先求差 x 年,有闰年问题。。。

再求 相差 y 月,有大小月,闰月问题。。。

再求差 z 日

另一个方案:先把每个日期类型转换为:距离公元1年1月1日,经过的天数。

在对象中只存这个整数。需要表达为具体年月日时,再转换回来。

求差变成一个十分容易的问题。

再完善类的功能

尽量保持私有,尽量不用友元,但…

  1. class Date{
  2. public:
  3. Date(){ k=0; }
  4. Date(int y, int m, int d){ k = ymd_to_k(y,m,d); }
  5. int year() { int y,m,d; k_to_ymd(k,y,m,d); return y; }
  6. int month() { int y,m,d; k_to_ymd(k,y,m,d); return m; }
  7. int day() { int y, m, d; k_to_ymd(k,y,m,d); return d; }
  8. int week_day() { return k % 7; }
  9. int operator-(Date& t){ return k - t.k; }
  10. Date operator+(int x){ Date t; t.k = k + x; return t; }
  11. Date operator-(int x){ return *this + (-x); }
  12. friend ostream& operator<<(ostream& os, Date& t);
  13. private:
  14. static bool leap_year(int y){
  15. return (y%4==0 && y%100!=0) || y%400==0;
  16. }
  17. static int ymd_to_k(int y, int m, int d);
  18. static void k_to_ymd(int k, int& y, int& m, int& d);
  19. int k; //记录距离 公元1年1月1日,过去了多少天。
  20. };

cout << 对象 的功能只能重载为友元函数

  1. ostream& operator<<(ostream& os, Date& t)
  2. {
  3. printf("%04d-%02d-%02d", t.year(), t.month(), t.day());
  4. return os;
  5. }

功能与实现的分离

同样的功能,我们可以设计不同的实现方式。

不同的实现方式,常常对应不同的数据结构。

所以,封装的含义是:隐藏实现细节,隐藏数据结构,暴露出功能性。

实时效果反馈

1. 按TDD的开发顺序是:__

  • A 先写出一个类如何被使用,再去实现它。

  • B 先写出一个类的功能,再去写如何调用它。

  • C 先写出类的数据结构,围绕结构定义功能。

  • D 先写出类的静态成员,再写成员函数。

2. 求某个日期的 n 天后是什么日期,较好的设计是:__

  • A 成员函数:Date& operator+(int x);

  • B 成员函数:Date operator+(int x);

  • C 友元函数:int operator+(Date& t, int x);

  • D 友元函数:Date& operator+(Date t, int x);

答案

1=>A 2=>B

日期类型(2)

image-20220115112212513

设计原则:

  1. 能私有的,不要公开(尽量保持封装性)
  2. 能成员,不友元(封装性)
  3. 能静态函数,不成员函数(降低复杂性,降低依赖)
  4. 需要 const 时,大胆使用
  5. 成员函数,能const, 尽量const

年月日与 int 互转

合适的数据结构,会降低问题的复杂度。

复杂度,往往是表达形式引起的,而非问题本身固有复杂度。

透过现象看本质,是程序员永远的追求。

  1. // 年月日 转为: 距离公元元年经过天数
  2. int Date::ymd_to_k(int y, int m, int d)
  3. {
  4. int M[] = {0,31,28,31,30,31,30,31,31,30,31,30,31};
  5. if(Date::leap_year(y)) M[2]++;
  6. int k = 0;
  7. for(int i=1; i<y; i++) k += 365 + leap_year(i);
  8. for(int i=1; i<m; i++) k += M[i];
  9. return k + d;
  10. }
  11. // 距离公元元年经过天数 转为: 年月日
  12. void Date::k_to_ymd(int k, int& y, int& m, int& d)
  13. {
  14. int M[] = {0,31,28,31,30,31,30,31,31,30,31,30,31};
  15. for(int i=1;; i++){
  16. int t = 365 + Date::leap_year(i);
  17. if(k<=t){
  18. y = i;
  19. break;
  20. }
  21. k -= t;
  22. }
  23. if(Date::leap_year(y)) M[2]++;
  24. for(int i=1;;i++){
  25. if(k<=M[i]){
  26. m = i;
  27. break;
  28. }
  29. k -= M[i];
  30. }
  31. d = k;
  32. }

这两个函数可以保持私有,静态就足够了

月份天数常量可以提升为静态成员(节约空间,提高速度)

保持功能不变,改变实现,称为:重构(refactoring)

是持续提升软件质量的重要手段。

ostream << 中的const

重载 Date + int, Date - int

  1. Date operator+(int x){ Date t; t.k = k + x; return t; }
  2. Date operator-(int x){ return *this + (-x); }

cout << 的怪现象

  1. Date c = a + 30;
  2. cout << c << endl;

与下列比较:

  1. cout << (a+30) << endl;

当对象为右值变量时,无法传入到非const形参中。

这就是为什么:形参变量是对象引用时,尽量const修饰

改为如下就可以了:

  1. friend ostream& operator<<(ostream& os, const Date& t);

实时效果反馈

1. 为什么形参为对象引用时,尽量 const 修饰:__

  • A 如果不用const修饰,当实参为临时对象时,无法自动转换

  • B 这样可以避免传递对象拷贝

  • C 可以防止传入临时对象

  • D 可以支持链式操作

答案

1=>A

有理数类

image-20220112192546762

有理数,就是可以表示为 p / q 的数(p, q 都是整数,互质)

使用有理数的好处是:可以不损失计算的精度,没有类似浮点数那样的舍入误差。

类的设计

需求是推动力。

提供哪些功能?

  1. 创建有理数
  2. 参与表达式的计算,返回新的有理数(+ - * /)
  3. 与整数的混合运算
  4. 显示
  1. Rati a(30,50);
  2. cout << a << endl; //显示 3/5 (约分后)
  3. cout << Rati(2) << endl; // 构造重载
  4. cout << Rati(1,3) + Rati(1,6) << endl;
  5. cout << (1+Rati(1,2)) << endl;

类的实现

  1. class Rati{
  2. public:
  3. Rati(){ p=1; q=1; }
  4. Rati(int a, int b){
  5. int g=gcd(a,b);
  6. p=a/g; q=b/g;
  7. }
  8. Rati(int a){ p=a; q=1; }
  9. Rati operator+(const Rati& t) const
  10. { return Rati(p*t.q+q*t.p, q*t.q); }
  11. Rati operator+(int t) const
  12. { return *this + Rati(t); }
  13. friend ostream& operator<<(ostream& os, const Rati& t);
  14. friend Rati operator+(int t1, const Rati& t2);
  15. private:
  16. static int gcd(int a, int b){
  17. if(b==0) return a;
  18. return gcd(b,a%b);
  19. }
  20. int p; // 分子
  21. int q; // 分母
  22. };
  23. Rati operator+(int t1, const Rati& t2)
  24. {
  25. return t2 + t1;
  26. }
  27. ostream& operator<<(ostream& os, const Rati& t)
  28. {
  29. cout << t.p;
  30. if(t.q != 1) cout << "/" << t.q;
  31. return os;
  32. }

拓展

  1. 请试着完成 减法,乘法,除法运算。

  2. 如何支持很大的整数呢?

    如果能用 bigint 替换 int 就好了。

    听说调和级数是无上限的,但收敛很慢,有多慢呢?

    考虑计算: 1 + 1/2 + 1/3 + 。。。 一共100万项,精确结果是多少?

实时效果反馈

1. 有理数,指的是:__

  • A 可以表达为有限小数的浮点数

  • B 可以表达为 p/q, p, q 都是整数,且 q!=0

  • C 所有的整数的四则运算的结果集合

  • D 可以表达为无限的不循环小数

2. operator+ 重载为成员,为什么对函数本身加 const 修饰:__

  • A 表示所有参数都是 const, 防止改变

  • B 表示返回值是 const

  • C 表示该函数不会改变任何对象的状态

  • D 表示该函数不会改变this指向对象的状态

答案

1=>B 2=>D

字符串类

image-20220112195830816

在许多高级语言中,字符串都是作为一个基本类型,或者对象来处理的。

c/c++中没有内置“串”这个类型,而是做了一个char* + ‘\0’结束的约定。

这种方案获得很高的效率

也带来很大不方便,而且,处理不好,就会内存泄漏。

  1. struct Student{
  2. char* name; // 相当凶险的定义!!
  3. int age;
  4. };

尝试自己定义一个 Str 类型

先规划它支持什么操作

也就是说,我们将如何使用它,期望它有什么样行为或表现。

(可以先降低对效率的要求,先实现功能再说)

需求

  1. Str a("abc"); // 需要由 const char* 构造
  2. Str b = "xyz"; // 需要由 const char* 构造
  3. Str c = b; // 需要由 Str 拷贝构造
  4. Str d;
  5. d = a + b + c + "1234";
  6. // Str + Str; Str + char*;
  7. // char* + Str; Str = char*; Str = Str
  8. cout << d << endl; // ostream << Str

Str 主要是对 char* 类型进行包装,负责监督、管理它的内存使用。

大体上就是要重载一些相关的运算符。

基本设计

让 Str 对象包含一个 char* 指针,指向 ‘\0’ 结束串,

并且,自动申请和释放内存空间。

Str 的主要职责:自动维护堆内存。

实现思路

通常的想法是,重载各路运算符。

也可以利用隐式转换

比如,

  1. 把类型 T 转为 Str 类型: 重载 拷贝构造 Str(T& )
  2. 把类型 Str 转为 T 类型: 重载成员函数 operator T()

实现

  1. class Str{
  2. public:
  3. Str(){ ps = NULL; }
  4. Str(const char* s){ copy(s); }
  5. Str(const Str& t):Str(t.ps){}
  6. ~Str(){ clear(); }
  7. Str& operator=(const Str& t){ return *this = t.ps; }
  8. Str& operator=(const char* s) {
  9. clear();
  10. copy(s);
  11. return *this;
  12. }
  13. Str operator+(const char* s) const {
  14. int n1 = ps? strlen(ps) : 0;
  15. int n2 = s? strlen(s) : 0;
  16. Str t;
  17. t.ps = new char [n1+n2+1];
  18. t.ps[0] = 0;
  19. if(ps) strcat(t.ps, ps);
  20. if(s) strcat(t.ps,s);
  21. return t;
  22. }
  23. operator const char*() const { return ps; }
  24. // Str -> const char*
  25. private:
  26. void clear(){
  27. if(ps){
  28. delete [] ps;
  29. ps=NULL;
  30. }
  31. }
  32. void copy(const char* s){
  33. if(s==NULL) {
  34. ps = NULL;
  35. return;
  36. }
  37. ps = new char[strlen(s)+1];
  38. strcpy(ps, s);
  39. }
  40. char* ps; // 被包装
  41. };

实时效果反馈

1. 关于const Str a, 说法正确的是:__

  • A a 对象是定常,其中的 ps 不能修改,ps 指向的堆内容也不能修改。

  • B a 对象是定常,其中的 ps 不能修改,ps 指向的堆内容可以修改。

  • C a 对象是定常,其中的 ps 可以修改,ps 指向的堆内容不能修改。

  • D 与编译环境有关

2. Str b = “abc”,会触发哪个重载的操作符:__

  • A 构造函数: Str( const char* )

  • B 赋值操作:Str& operator=(const char* )

  • C 类型转换: operator char* ( )

  • D 必须重载为友元函数:friend operator=(Str&, const char*)

答案

1=>B 2=>A

字符串类(2)

image-20220119081513197

与其它高级语言比较起来,c/c++ 是更注重效率的语言。

前边自定义的简陋 Str 类,当然还有许多问题(比如,功能不丰富)。

这里,我们着重讨论效率有关的问题。

考虑如下的循环:

  1. Str a;
  2. for(int i=0; i<10; i++){
  3. a = a + i;
  4. }
  5. // a: "0123456789"

需要重载 operator+ (int)

可以,借用已经有的 operator+(const char*)

再,新定义构造 Str(int) 把 int 转化为 Str 对象

  1. Str(int x){
  2. ps = new char[30];
  3. itoa(x, ps, 10);
  4. }
  5. // Str + int --> Str + Str --> Str + char*
  6. Str operator+(int x) const { return *this + Str(x); }

这样,的确可以实现功能。

但,过于频繁地创建和销毁对象。

可以通过析构函数来观察这期间有多少对象出生:

  1. ~Str(){ clear(); cout << "~ "; }

为了减少对象折腾,我们可以提供 += 操作符,修改左值对象,而不是返回新的。

  1. Str& operator+=(const char* s){
  2. if(s==NULL) return *this;
  3. char* p = ps;
  4. ps = new char [len()+strlen(s)+1];
  5. ps[0] = 0;
  6. if(p) strcat(ps, p);
  7. strcat(ps, s);
  8. if(p) delete [] p;
  9. return *this;
  10. }
  11. Str operator+(const char* s) const {
  12. Str t(*this);
  13. t += s;
  14. return t;
  15. }

使用的时候:

  1. Str a;
  2. for(int i=0; i<10; i++){
  3. a += Str(i);
  4. }

再仔细观察,这个时候,串的行为是:每次 += 都会新分配内存,老的释放。

很容易想到,能不能一次分配留点富裕,下次够用就不要重新分了。

这是个重要的设计:

预留空间的设计

去看一些工业库中类,经常会发现,len(或size)以外,还有个 cap,是什么意思?

对于我们的 Str 类, 就可以增加一个 cap,表示预留的 容量(capacity) 概念。

当需求小于这个容量,就直接原地“扩建”,

否则,重新分配空间(多申请些,作为新的预留)。老内容拷贝过来。

代码重构后

  1. class Str{
  2. public:
  3. Str(int n){
  4. if(n<10) n=10;
  5. cap = n;
  6. ps = new char [cap];
  7. *ps = '\0';
  8. }
  9. Str():Str(10){}
  10. Str(const char* s):Str((s?strlen(s):0)+10){
  11. if(s) strcpy(ps, s);
  12. }
  13. Str(const Str& t):Str(t.ps){}
  14. ~Str(){
  15. delete [] ps;
  16. //cout << "~ ";
  17. }
  18. Str& operator=(const Str& t){ return *this = t.ps; }
  19. Str& operator=(const char* s) {
  20. int n = (s? strlen(s) : 0) + 1;
  21. if(cap < n){
  22. cap = n * 2;
  23. delete [] ps;
  24. ps = new char [cap];
  25. }
  26. if(s)
  27. strcpy(ps, s);
  28. else
  29. *ps = '\0';
  30. return *this;
  31. }
  32. Str operator+(int x) const { return *this + Str(x); }
  33. Str& operator+=(const char* s){
  34. if(s==NULL) return *this;
  35. char* p = ps;
  36. int n = strlen(ps) + strlen(s) + 1;
  37. if(cap < n){
  38. cap = n * 2; // 多分配些
  39. ps = new char [cap];
  40. strcpy(ps, p);
  41. delete [] p;
  42. }
  43. strcat(ps, s);
  44. return *this;
  45. }
  46. Str operator+(const char* s) const {
  47. Str t(*this);
  48. t += s;
  49. return t;
  50. }
  51. // 为了隐式转换,// Str -> const char*
  52. operator const char*() const { return ps; }
  53. // 显示转换:从 Int 转为 Str
  54. static Str from(int x){
  55. Str t(30);
  56. itoa(x, t.ps, 10);
  57. return t;
  58. }
  59. private:
  60. char* ps; // 被包装主体
  61. int cap;
  62. };

调用处轻微改变:

  1. Str a;
  2. for(int i=0; i<10; i++){
  3. a += Str::from(i); // Str(int) 构造被占用了
  4. }

拓展

如果不可惜浪费点存储,可以同时保存串长度和容量。

可以方便地找到串尾,方便反向遍历,串的拼接等动作,提高效率。

实时效果反馈

1. 关于Str类的串长度和容量的说法,正确的是:__

  • A 容量等于串长度加 1

  • B 容量总是大于串长度

  • C 当串为空串时,容量等于 0

  • D 容量不足时,总是申请当前容量2倍的容量。

2. 把类型 T 的数据转为 Str 类型,==不可行==的方案是:__

  • A 定义 Str 类型的构造函数 Str(T&)

  • B 定义 T 类型的转换操作符: operator Str()

  • C Str 中定义静态函数 static Str::from(T&)

  • D 定义普通函数 void t_to_str(T a, Str b);

答案

1=>B 2=>D

继承

章节二:信息学竞赛 CPP 对象篇 - 图48

继承与组合(聚合)

继承一种重型的解决方案,在设计的时候要充分估计到需求可能的变化。

如果当初考虑不足,等到继承了很多类以后,再动祖先类就相当困难了。

仅仅为了达到重用的目的,

优先考虑组合或聚合的方案,其次才考虑用继承。

如果,B 类继承了 A 类,则 B 类具有了 A 类的全部能力。

此时,A类称为: 基类(base class) ,父类,超类

相对地,B类称为:派生类(derived class),子类,继承类

章节二:信息学竞赛 CPP 对象篇 - 图49

重用已有的功能

我们使用继承的最基本诉求是:重用已经存在的功能。

当软件变得庞大的时候,重用变得苛刻。

尽量不改动已经存在模块(即便是有源代码)

所谓:只扩展(extend),不修改

【示例】

Cnt 类的基本情况

  1. class Cnt{
  2. public:
  3. Cnt(){n=0;}
  4. void inc(){n++;}
  5. int get(){return n;}
  6. private:
  7. int n;
  8. };

比如,增补一个限制最大功能(最大x封顶),

还要顺便支持一下链式操作

  1. Cnt2 a(3);
  2. a.inc().inc().inc().inc();
  3. cout << a.get() << endl;

注意,不可以修改Cnt类

可以才用两种方案:

  1. 继承

    1. class Cnt2: public Cnt{
    2. public:
    3. Cnt2(int m){ max = m; }
    4. Cnt2& inc(){
    5. if(get() >= max) return *this;
    6. Cnt::inc();
    7. return *this;
    8. }
    9. private:
    10. int max;
    11. };
  2. 组合

    1. class Cnt3{
    2. public:
    3. Cnt3(int m){ max = m; }
    4. Cnt3& inc(){
    5. if(cnt.get() >= max) return *this;
    6. cnt.inc();
    7. return *this;
    8. }
    9. int get() { return cnt.get(); }
    10. private:
    11. int max;
    12. Cnt cnt;
    13. };

继承的优势

继承是一种 is-a 关系,组合是has-a关系

这样,在传参的时候,有兼容性

也就是说,子类可以冒充(顶替)父类去完成任务

  1. void f(Cnt& x) // 这里,需要的是:Cnt类型
  2. {
  3. cout << x.get() << endl;
  4. }

我们可以出入Cnt2 对象,但不能传入Cnt3的对象

实时效果反馈

1. 如果B类继承了A类, B和A的关系,说法正确的是:__

  • A is-a 关系

  • B has-a 关系

  • C ref-a 关系

  • D use-a 关系

2. B类继承A类后,哪个动作==不能==做出:__

  • A 覆盖A类中的同名public成员函数

  • B 访问A类的public成员

  • C 访问A类的private成员

  • D 增添新的成员

答案

1=>A 2=>C

继承后的权限

image-20220114211128817

B类继承A类后,权限比“陌生人”访问A类,更多一些。

  1. class A{
  2. public:
  3. // 此部分,任何人都可以访问到
  4. protected:
  5. // 此部分,亲疏有别,继承人可以访问到,陌生人不可
  6. private:
  7. // 此部分,除了自己和特殊授权的友元,其它人都不能访问到
  8. };

考虑到B类对A类的继承方式,

权限系统的访问控制,可以更细腻。

基本的访问控制:

从哪里访问 public protected private
本类中 yes yes yes
public 派生类 yes/继承为public yes/继承为protected no
protected 派生类 yes/继承为protected yes/继承为protected no
private 派生类 yes/继承为private yes/继承为private no
陌生人 yes no no

注意,默认的继承方式是:private,

也就是说,此时,外界根本访问不到父类定义的public函数

  1. class A{
  2. public:
  3. void f(){}
  4. };
  5. class B:A{};
  6. // 当外界如下访问的时候:
  7. B x; x.f(); // f() 是private,访问不到

继承与友元

友元关系是单向的。

友元关系不能被继承。

  1. class A{
  2. public:
  3. friend void fa(A& t);
  4. private:
  5. int a;
  6. };
  7. class B: public A{
  8. public:
  9. friend void fb(B& t);
  10. private:
  11. int b;
  12. };
  13. void fa(A& t){ t.a++; }
  14. void fb(B& t){ t.b++; t.a++; } // 不可以,必须在A类中授权

单独放开权限:

  1. class B; // 注意这个前向声明,否则编译不过
  2. class A{
  3. public:
  4. friend void fa(A& t);
  5. friend void fb(B& t);
  6. private:
  7. int a;
  8. };

继承与静态

静态成员变量,只有一份。

类内声明,类外定义(初始化)

无论通过类名访问,还是通过对象访问,都是同一个东西,

而且,只有一份。

所以,根本不存在继承不继承的问题。

实时效果反馈

1. 如果 class B: private A, 说法正确的是:__

  • A 从B类的实例,无法访问A类的public成员

  • B 从B类的实例,可以访问A类的public成员

  • C 从B类的实例,可以访问A类的public,protected成员

  • D 从B类中无法访问A类的public成员

2. 当有继承时,父类中的静态变量如何继承?:__

  • A 子类直接继承,此时,无论创建多少个对象,总是存在两份该静态变量。

  • B 子类直接继承,但需要类外重新声明该变量

  • C 实际上与子类的继承无关,但语法上可以通过子类的实例来访问。

  • D 与子类的继承方式有关,当private继承时,父类的静态变量消失。

答案

1=>A 2=>C

多继承与二义性

image-20220115215156165

多重继承

比较另类的,c++支持多继承,大多数高级语言不支持。

这样做的好处,更方便地支持代码重用。

另外,与现实世界一致(同一对象,多重身份):

比如:一位参赛的学生,既是学生,也是运动员。

许多高级语言为了解决这个问题,引入了接口的概念,c++当然能模拟出接口

同时,也支持了具体类的多继承,这有一定风险。

章节二:信息学竞赛 CPP 对象篇 - 图52

解决冲突的方案:

  1. 用类名限定

    1. 体育特长生 x("zhang");
    2. x.运动员::编号 = ...
    3. x.学生::编号 = ...
  2. 派生类中再定义:编号,覆盖掉父类的编号体系。

    在新编号中可以选用某个父类的编号,

    也可以重新设计编号,从而完全隐藏了父类的编号体系。

菱形继承问题

菱形继承指的是:从不同分支,继承了同一祖先,这样,该祖先的数据就存在多份

章节二:信息学竞赛 CPP 对象篇 - 图53

在这种情形下,同样会出现歧义。

解决的方法还是用类名限定路径,或者做覆盖处理。

  1. class A{
  2. public:
  3. int a;
  4. };
  5. class B1: public A{};
  6. class B2: public A{};
  7. class C:public B1, public B2{};

使用方法:

  1. C x;
  2. x.B1::a = 10;
  3. x.B2::a = 20;
  4. cout << x.B1::a << "," << x.B2::a << endl;

虚继承

我们可以指定公共基类不要重复继承。

在指定继承方式的时候,增加修饰 virtual,称为虚继承。

这时,被继承的基类称为:虚基类。

  1. class A{
  2. public:
  3. int a;
  4. };
  5. class B1: virtual public A{};
  6. class B2: virtual public A{};
  7. class C:public B1, public B2{};

此时,C 的实例只有一份 int a

实时效果反馈

1. 为解决c++多继承引起的二义性, ==不正确==的说法是:__

  • A 通过继承路径上的类名限定

  • B 覆盖该名字,提供新的版本

  • C 通过private继承,使得某一路径对外不可见

  • D 通知基类作者,要求他使用其它的名字

2. 在使用多继承时,为了使得公共基类只有一份数据,应该怎么办?:__

  • A 以 virtual 修饰该公共基类

  • B 继承该基类时,采用virtual继承方式

  • C 把该基类的数据设置为 protected 类型

  • D 把该基类的数据设置为 virtual 类型

答案

1=>D 2=>B

继承中的构造函数

image-20220115215619580总结构造函数:

  1. 语法:与类同名,无返回值(不是void)
  2. 作用:初始化对象的成员数据,使对象进入“有效状态”
  3. 调用时机:当对象出生时,自动被调用。
  4. 类别:默认的无参构造,一般重载构造,拷贝构造
  5. 与普通成员函数区别:不能随意调用,初始化列表

如何调用父类的构造函数

按c++规定,对象的数据一定要初始化

当有继承后…

子类对象包含了父类的数据,这部分数据可能是private,子类访问不到,

只能由父类自己的构造函数来完成初始化。

因此,执行任何子类构造函数之前,总是先执行父类的构造函数

执行哪个? 可以通过初始化列表来控制。

  1. struct A{
  2. A(){ cout << "A::A()" << endl; }
  3. };
  4. struct B:A{
  5. B(int x){ cout << "B::B(int)" << endl; }
  6. };
  7. // 调用处:
  8. B x(100);

章节二:信息学竞赛 CPP 对象篇 - 图55

假如A类中,有多个版本的构造函数,我们可以在B类构造函数的初始化列表中,

选择调用哪一个。

如果不指定,则调用无参构造。

  1. struct A{
  2. A(){ cout << "A::A()" << endl; }
  3. A(int a, char* b){ cout << "A::A(int, char*)" << endl; }
  4. };
  5. struct B: public A{
  6. B():A(10,"abc"){ cout << "B::B()" << endl; }
  7. B(int x){ cout << "B::B(int)" << endl; }
  8. };
  9. int main()
  10. {
  11. B x(100);
  12. B y;
  13. return 0;
  14. }

章节二:信息学竞赛 CPP 对象篇 - 图56

注意:

子类的构造函数之初始化列表,只能控制怎么调用父类的构造,不能控制更远的“祖父”类,

那个是父类的初始化别表的职责。

至此,初始化列表的职责:

  1. 基类初始化
  2. 内嵌对象的初始化
  3. 本类 const 成员初始化

c++11标准的拓展

如果父类有多个版本的构造函数,

按照子类完全顶替父类的原则,子类什么都不做,即可具有父类全部能力。

然而,构造函数并没有被子类继承,如果要获得父类的构造能力,子类必须当二传手。

  1. struct A{
  2. A(){ cout << "A::A()" << endl; }
  3. A(int){ cout << "A::A(int)" << endl; }
  4. A(int, int){ cout << "A::A(int,int)" << endl; }
  5. };
  6. struct B:A{
  7. B():A(){}
  8. B(int a):A(a){}
  9. B(int a, int b):A(a,b){}
  10. };
  11. int main()
  12. {
  13. B a;
  14. B b(1);
  15. B c(1,2);
  16. return 0;
  17. }

当构造函数版本很多的时候,这种“二传手”很枯燥,

c++11 引入了继承基类构造函数的方法:

  1. struct A{
  2. A(){ cout << "A::A()" << endl; }
  3. A(int){ cout << "A::A(int)" << endl; }
  4. A(int, int){ cout << "A::A(int,int)" << endl; }
  5. };
  6. struct B: public A{
  7. using A::A; //所有A类构造函数全继承
  8. };

这其实是个特例,对普通的成员函数也可以这样来避免覆盖问题。

  1. struct A{
  2. void f() { cout << "A::f" << endl; }
  3. };
  4. struct B: public A{
  5. using A::f;
  6. void f(){ cout << "B::f" << endl; }
  7. };
  8. int main()
  9. {
  10. B a;
  11. a.f();
  12. a.A::f();
  13. return 0;
  14. }

这是有用的,有时候,孙子类可能想越过父类,访问祖父类的 f 方法。

虚基类的初始化

多继承的时候,虚基类的数据只有一份,谁来控制它的初始化呢?

c++规定,由最后一个继承者来控制虚基类的初始化。

  1. struct A{
  2. A(){ cout << "A::A()" << endl; }
  3. A(int){ cout << "A::A(int)" << endl; }
  4. };
  5. struct B1: virtual public A{
  6. B1(){ cout << "B1::B1()" << endl; }
  7. };
  8. struct B2: virtual public A{
  9. B2(){ cout << "B2::B2()" << endl; }
  10. };
  11. struct C: public B1, public B2{
  12. C():A(33){ cout << "C::C()" << endl; }
  13. };
  14. int main()
  15. {
  16. C x;
  17. return 0;
  18. }

章节二:信息学竞赛 CPP 对象篇 - 图57

注意,构造函数的执行顺序还是A::A 最先,

但其选择权及喂入的参数在C类的初始化列表中才有效。

实时效果反馈

1. 当有继承关系时,关于构造函数的说法,正确的是:__

  • A 子类的构造函数负责初始化自己的成员数据,以及父类的成员数据

  • B 子类的构造函数只负责初始化自己的成员数据,父类的由其默认构造完成

  • C 子类的构造函数只负责初始化自己的成员数据,但可以在其初始化列表选择父类构造

  • D 子类构造函数要负责自己,以及所有祖先类的成员数据的初始化

2. 以下,哪项==不是==初始化列表的职责:__

  • A 选择父类的构造函数,并传入参数

  • B 初始化本类嵌入的对象

  • C 初始化本类的 const 成员数据

  • D 初始化本类的 static 成员数据

答案

1=>C 2=>D

继承下的内存模型

image-20220115220433264

当没有继承的时候,

对象只包含成员数据(不包括成员函数,不包括静态成员数据)

当有继承的时候,

子类对象size >= 父类对象size

父类的private数据也会包含在子类的对象中(但通过父类的函数来访问)

如何探索内存结构

有些IDE提供了辅助工具,比如,vs

手动输出变量的地址进行分析。

  1. struct A{
  2. int a;
  3. };
  4. struct B1:A{
  5. int b1;
  6. };
  7. struct B2:A{
  8. int b2;
  9. };
  10. struct C:B1,B2{
  11. int c;
  12. };
  13. // 调用方法:
  14. C x;
  15. cout << sizeof(x) << endl;
  16. cout << &(x.B1::a) << endl;
  17. cout << &(x.b1) << endl;
  18. cout << &(x.B2::a) << endl;
  19. cout << &(x.b2) << endl;
  20. cout << &(x.c) << endl;

运行结果:

章节二:信息学竞赛 CPP 对象篇 - 图59

可以分析出,此时的内存模型是:

章节二:信息学竞赛 CPP 对象篇 - 图60

可以采用同样的方法来观察,虚继承的情况:

  1. struct A{
  2. int a;
  3. };
  4. struct B1:virtual A{
  5. int b1;
  6. };
  7. struct B2:virtual A{
  8. int b2;
  9. };
  10. struct C:B1,B2{
  11. int c;
  12. };
  13. int main()
  14. {
  15. C x;
  16. cout << sizeof(x) << endl;
  17. cout << &x << endl;
  18. cout << &(x.B1::a) << endl;
  19. cout << &(x.b1) << endl;
  20. cout << &(x.B2::a) << endl;
  21. cout << &(x.b2) << endl;
  22. cout << &(x.c) << endl;
  23. return 0;
  24. }

image-20220124120806682

当同时还有组合或聚合

组合或聚合都属于本类的数据,在本类数据区处存放。

不同的是,

组合(也称为:内嵌对象)时,包含整个内嵌对象。

聚合时,只包含一个指向聚合对象的指针数据。

实时效果反馈

1. 当有继承时, 子类对象内存模型,说法正确的是:__

  • A 子类对象只包含本类型数据,但包含直接父类数据区的指针。

  • B 子类对象包含本类型非静态成员数据,以及所有祖先类的非静态成员数据

  • C 子类对象仅仅包含本类型的所有成员数据。

  • D 子类对象的大小与继承方式无关

2. 如果子类中还聚合了T类的对象,说法正确的是:__

  • A 子类数据包含T类数据,并负责T类对象的初始化

  • B 子类数据包含指向T类对象的指针,一般不负责管理T对象的生存期

  • C 与子类对象继承T类后的内存模型相同

  • D 子类数据包含指向T类对象指针,并在初始化列表,对T类对象初始化

答案

1=>B 2=>B

指针的泛化

image-20220115221139864

指针泛化指的是:基类的指针,指向子类的对象。

通过该指针可以调用所有基类的功能,但不能调用子类的功能。

  1. class A{
  2. public:
  3. void f() { cout << "A::f()" << endl; }
  4. private:
  5. int a;
  6. };
  7. class B: public A{
  8. public:
  9. void f() { cout << "B::f()" << endl; }
  10. private:
  11. int b;
  12. };
  13. int main()
  14. {
  15. B x;
  16. x.f();
  17. A* p = &x; // 指针泛化
  18. p->f();
  19. return 0;
  20. }

逻辑上的可行性

子类对象可以被看作:特殊的父类对象。

这正是 is-a 关系的体现。

这与现实世界一致:猫,狗,鸭子都可以当作动物来对待。

子类对象拥有父类的全部能力,此外,还增添了新的特征。

泛化,就是忽略子类对象的特殊性质,而讨论它从父类继承的一般性质。

物理上的可行性

从内存上来分析,子类包含了父类的所有成员数据。

所有,子类的一部分,完全可以作为父类对象来使用,逻辑上,与父类自己生成的对象没有什么差异。

章节二:信息学竞赛 CPP 对象篇 - 图63

那么,多继承的情况呢?

  1. struct A{
  2. void f() { cout << "A::f()" << endl; }
  3. int a;
  4. };
  5. struct A2{
  6. int a2;
  7. };
  8. struct B: A, A2{
  9. void f() { cout << "B::f()" << endl; }
  10. int b;
  11. };
  12. int main()
  13. {
  14. B x;
  15. cout << &x << endl;
  16. A2* p = &x;
  17. cout << p << endl;
  18. return 0;
  19. }

泛化的目的

对象不按照原来的类型来使用,为什么要泛化它呢?

泛化的目的是为了多态—-相同的调用,调用到不同的函数。

可以简单地理解为:把一些不同的东西做统一的处理(当然,它们有共性)。

泛化就是:甩开特性,只看共性。

拓展问题

  1. 与泛化相反,“指针特化”可以不?

    1. 脊椎动物 a;
    2. 猫* p = &a; // 这会编译不过
    3. // 显然,a不是猫,如果通过p调用猫类方法,可能会失败!!
  2. 一定指针才可以泛化吗?对象可不可以泛化?

    一般很少这么做,但可行。

    1. struct A{int a;};
    2. struct B:A{int b;};
    3. int main()
    4. {
    5. A x;
    6. B y;
    7. y.a = 100;
    8. x = y;
    9. cout << x.a << endl;
    10. return 0;
    11. }

实时效果反馈

1. 指针泛化的目的是什么?__

  • A 目的是访问 子类 对象的私有数据

  • B 为了缩小子类对象大小,节约存储

  • C 实现动态多态效果

  • D 解决多重继承的名字冲突

2. 如果 B 是 A 的子类,A a; B b; A pa; B pb; 下列哪个报编译错__

  • A pa = &b;

  • B pb = &a;

  • C a = b;

  • D a = *pb;

答案

1=>C 2=>B

多态

image-20220115222356755

重载可能是:overload, 或者 override

一般,前者指静态重载,就是编译期间的重载。

比如 int max(int, int) 和 int max(int, int, int) ,仅仅函数名碰巧相同而已。

也有称为静态多态的。

后者指动态重载,就是说,编译期间也确定不了调哪个函数。

动态重载,也就是我们常听说的多态。

面向对象的特征有:封装,继承,多态等。

多态可以说是面向对象的华彩乐章,是面向对象的强大武器。

发现多态现象

多态,即:动态重载,也就是说,在编译期间无法确定调用哪个函数,

直到运行时才能确定下来。

  1. struct A{
  2. virtual void f(){ cout << "A::f" << endl; }
  3. };
  4. struct B1:A{
  5. virtual void f(){ cout << "B1::f" << endl; }
  6. };
  7. struct B2:A{
  8. virtual void f(){ cout << "B2::f" << endl; }
  9. };
  10. int main()
  11. {
  12. A* p;
  13. int x;
  14. cin >> x;
  15. if(x<5)
  16. p = new B1();
  17. else
  18. p = new B2();
  19. p->f(); // 如何编译这个 f()?
  20. return 0;
  21. }

上边的f(),没有在编译期间确定下来。

根据运行时,指向的对象不同,调用了不同的f()

有何实用价值?

为了把不同的东西当相同的类型处理。以便软件的“蓝图化”设计。

所谓高层编码,可以不管具体细节。

假设有一个桌面绘图软件,我们用伪代码实现重绘功能:

  1. class 图形{
  2. public:
  3. virtual void paint(画刷& br); //重绘图形
  4. ....
  5. private:
  6. Point 当前位置;
  7. ....
  8. };
  9. class 圆形:public 图形{
  10. public:
  11. virtual void paint(画刷& br);
  12. ...
  13. };
  14. class 三角形:public 图形{
  15. virtual void pain(画刷& br);
  16. };
  17. ////// 窗口管理中:
  18. void redraw(图形列表 L){
  19. br = 创建画刷();
  20. for(L::Iter i=L.begin(); i!=L.end(); i++){
  21. i->paint(br);
  22. }
  23. 销毁画刷(br);
  24. }

L是图形列表,持有的元素是各种不同类型的指针(圆形*, 三角形*等 )

我们要对这些不同类型的对象大喊:“绘制自己!”,这该如何实现。

如果没有多态,恐怕:

  1. if(i is 圆形指针) i->圆的绘制(br);
  2. else if(i is 三角形指针) i->三角形的绘制(br);
  3. else if ...

这和容易出错,又很难维护。

实时效果反馈

1. 关于静态重载( overload), 说法正确的是:__

  • A void f(int a); int f(int b); 构成重载

  • B void f(int a, int b=10); void f(int a); 构成重载

  • C void f(int a); void f(char* s); 构成重载

  • D void f(int a); void g(int a); 构成重载

2. 多态有何用处?__

  • A 向不同类型的对象发出同样的命令,却执行不同的动作。

  • B 让不同类型的对象具有同样的大小,以便使用对象数组。

  • C 让不同类型的指针指向同一块基类的数据

  • D 为了获得比静态重载更快的执行速度

答案

1=>C 2=>A

虚函数表

image-20220115224657862

如何形成动态多态的效果?

通过:父类虚函数加 virtual 修饰,子类中重写,指针泛化。

image-20220126085623841

本节探究一下多态的实现方法。

观察对象的大小

  1. struct A{
  2. void fa(){}
  3. void fb(){}
  4. void ga();
  5. void gb(int);
  6. int a;
  7. };
  8. struct B{
  9. virtual void fa(){}
  10. virtual void fb(){}
  11. void ga();
  12. void gb(int);
  13. int a;
  14. };
  15. int main()
  16. {
  17. A a;
  18. B b;
  19. cout << sizeof(a) << endl;
  20. cout << sizeof(b) << endl;
  21. }

章节二:信息学竞赛 CPP 对象篇 - 图67

我们可以看到,当增加了 virtual 修饰,该类的实例增加了 4 个字节,这是个指针。

该指针在对象的最开始处,指向一个虚函数表

就是记录了,该类中可访问的所有虚函数的函数指针的表。

当调用虚函数时,首先拿到这个对象里的指针,找到表,调用表中的虚函数。

如果是普通函数,则不通过这个表,直接静态编译。

image-20220125093256543

那么,当有继承的时候,如何实现的多态呢?

继承下的虚函数

  1. struct A{
  2. virtual void fa(){ cout << "A:fa()" << endl; }
  3. virtual void fb(){ cout << "A:fb()" << endl; }
  4. void ga(){ cout << "A:ga()" << endl; }
  5. void gb(int);
  6. int a;
  7. };
  8. struct B:A{
  9. virtual void fa(){ cout << "B:fa()" << endl; }
  10. void ga(){ cout << "A:ga()" << endl; }
  11. int b;
  12. };
  13. int main()
  14. {
  15. A* p = new B(); //指针泛化
  16. p->fa();
  17. p->fb();
  18. p->ga();
  19. return 0;
  20. }

其运行结果:

章节二:信息学竞赛 CPP 对象篇 - 图69

fa 是虚函数,所有实现了多态的效果。

fb是虚函数,但子类没有覆盖,所以还是调用A::fb()

ga不是虚函数,不通过多态机制,直接编译为:A::ga()

image-20220125103919066

虚函数表,每个类有唯一的一份。

如果类中有虚函数,则其对象头部含有一个指向本类虚函数表的指针。

虚函数中列出了从本类实例调用的所有虚函数的函数指针。

拓展

c++11 中引入了 override, final 做更明确的指示。

防止手抖,定义了新的虚函数,而不是覆盖父类的。

  1. virtual void fa() override; // 明确要覆盖父类同名函数

而final则表明,本函数不许覆盖。

  1. void f() final;

实时效果反馈

1. 要形成动态多态效果,哪个==不是==必须的:__

  • A 父类中定义了虚函数

  • B 子类覆盖父类中的虚函数

  • C 父类指针,指向了子类对象,并且调用虚函数

  • D 在子类覆盖虚函数中,再去调用父类的虚函数

2. 关于虚函数表,说法正确的是:__

  • A 所有类都对应同一个虚函数表

  • B 所有含有虚函数的对象都对应单独的虚函数表

  • C 每个含有虚函数(包括继承来的)的类对应唯一的虚函数表

  • D 包含虚函数的类实例化时,临时创建虚函数表

答案

1=>D 2=>C

虚析构函数

image-20220116081205041

虚函数表会占用内存,

调用虚函数有查表动作,增加处理时间。

但,有些场合,使用虚函数是必要的。

比较典型的场合:虚析构函数。

应用场景

考虑如下场景:

Shape 类有许多派生: Circle, Rect, Triangle 等

通过Shape指针数组来管理多个图形对象。

这里出现了指针泛化现象

现在来观察,回收对象内存时的景象。

  1. struct Shape{
  2. Shape(){ cout << "create Shape" << endl; }
  3. ~Shape() { cout << "destroy Shape" << endl; }
  4. };
  5. struct Circle:Shape{
  6. Circle(){ cout << "create Circle" << endl; }
  7. ~Circle(){ cout << "destroy Circle" << endl; }
  8. };
  9. struct Rect:Shape{
  10. Rect(){ cout << "create Rect" << endl; }
  11. ~Rect(){ cout << "destroy Rect" << endl; }
  12. };
  13. struct Triangle:Shape{
  14. Triangle(){ cout << "create Triangle" << endl; }
  15. ~Triangle(){ cout << "destroy Triangle" << endl; }
  16. };
  17. int main()
  18. {
  19. Shape* x[3];
  20. x[0] = new Circle();
  21. x[1] = new Rect();
  22. x[2] = new Triangle();
  23. for(int i=0; i<3; i++) delete x[i]; //必须写,否则泄漏
  24. return 0;
  25. }

来看看执行结果:

章节二:信息学竞赛 CPP 对象篇 - 图72

这里,没有准确调用具体类的析构函数,可能会引发问题。

实时效果反馈

1. 关于析构函数, 说法正确的是:__

  • A 析构函数无参,没有返回值,无重载版本

  • B T* 指针变量离开作用域,自动调用 T 的析构

  • C 析构子类对象,不会自动调用父类的析构

  • D 函数返回的临时对象不会自动调用析构

2. 把析构函数做成虚函数,好处是:__

  • A 节省内存

  • B 更快速完成清理工作

  • C 防止内存泄漏

  • D 当出现指针泛化时,调用到对象真实类型的析构函数

答案

1=>A 2=>D

RTTI机制

image-20220116082034200

RTTI 即: RunTime Type Identification (运行时类型识别)

在指针泛化后,多态的功能很强大,能满足大多数情况的应用。

但,有时,我们希望获得指针指向的对象的真实类型。

这就需要RTTI了。

c++不具备类似java的完备的反射机制,但RTTI可以完成很多任务。

typeid

typeid 是操作符,它可以返回对象的真实类型。

返回值是一个type_info结构,支持一些运算,有name()函数。

可以比较两个type_info是否相同,从而知道是否为同一个类型。

以下代码需要include:

  1. #include <typeinfo>
  1. struct A{virtual void f(){}};
  2. struct B:A{int x;};
  3. int main()
  4. {
  5. A* p = new B();
  6. cout << typeid("abc").name() << endl;
  7. cout << typeid(123.5).name() << endl;
  8. cout << typeid(p).name() << endl;
  9. cout << typeid(*p).name() << endl;
  10. cout << typeid(B).name() << endl;
  11. return 0;
  12. }

名字比较奇怪,一般不直接使用。

要保证找到真正的子类,A必须含有虚函数,因而对象中含有虚函数表的指针。

判断对象的真实类型惯用法:

  1. A* p = ....
  2. if(typeid(*p)==typeid(B)) ...

向下转型

泛化,是把一个具体类型的对象指针转为:更一般,更抽象类型的指针(向上转型)

向上转型是自动的,有的时候,也会有向下转型的需要:

  1. A* p = ....
  2. B* q = (B*)p; // 这是个危险的方案!!因为有多继承

我们知道,当有多继承的时候,父类指针的值并不一定等于子类对象的首地址。

所以,强制认为该值就是子类对象的开始位置是武断的!

image-20220126110256720

更安全的向下转型方法:

  1. struct A{
  2. virtual void f(){ cout << "A::f()" << endl; }
  3. };
  4. struct B{
  5. virtual void g(){ cout << "B::g()" << endl; }
  6. };
  7. struct C:A,B{
  8. void h() { cout << "C::h()" << endl; }
  9. };
  10. int main()
  11. {
  12. B* p = new C();
  13. if(typeid(*p)==typeid(C)){
  14. C* q = dynamic_cast<C*>(p);
  15. q->h();
  16. }
  17. return 0;
  18. }

实时效果反馈

1. 关于typeid, 说法正确的是:__

  • A 它是系统函数,返回变量的类型

  • B 与sizeof一样,是运算符,返回的是type_info结构体

  • C 是运算符,返回类型名字

  • D 是函数,返回类型的标识号

2. 泛化是向上转型,那么,如何向下转型?__

  • A 编译器会自动转换

  • B 强制转换:(类型)x

  • C dynamic_cast<类型>(x)

  • D 不允许

答案

1=>B 2=>C

抽象类型

image-20220116083111685

纯虚函数:只约束形式,但不去实现的函数。

  1. virtual void f() = 0; // 只声明,不去实现

只要包含一个纯虚函数,就无法实例化。

纯虚函数只能等待子类去覆盖它。

含有纯虚函数的类,无法实例化,其用途:

  1. 为子类设置统一的约束。强制子类型必须实现它。
  2. 阻止对本类型的实例化
  3. 它的指针类型可以用作泛化的指针

储蓄账户的示例

image-20220126125029058

在储蓄账户类里,

完成年利率的计算过程,并且不允许子类去覆盖。

计算过程中,用到月利率,这个是不确定的,银行与具体的用户签订了不同的协议。

(比如,关联各种优惠、促销等活动)

  1. #include <iostream>
  2. using namespace std;
  3. class SavAcc{
  4. public:
  5. SavAcc() { balance = 0; }
  6. virtual int year_interest() final{
  7. int sum = balance;
  8. for(int i=0; i<12; i++){
  9. double k = sum * mon_inter_rate(i) / 100;
  10. sum += (int)( k + 0.5);
  11. }
  12. return sum - balance;
  13. }
  14. // 强制子类必须完成
  15. virtual double mon_inter_rate(int x) = 0;
  16. void deposit(int x){ //存款
  17. balance += x;
  18. }
  19. int get_balance() { return balance; }
  20. protected:
  21. int balance; //余额
  22. };
  23. class T1SavAcc: public SavAcc{
  24. public:
  25. virtual double mon_inter_rate(int x) override {
  26. if(x<3) return 0.3;
  27. return 0.25;
  28. }
  29. };
  30. class T2SavAcc: public SavAcc{
  31. public:
  32. virtual double mon_inter_rate(int x) override {
  33. return 0.3;
  34. }
  35. };
  36. int main()
  37. {
  38. T1SavAcc a;
  39. a.deposit(10000);
  40. cout << a.year_interest() << endl;
  41. cout << a.get_balance() << endl;
  42. return 0;
  43. }

实时效果反馈

1. 如何强制子类必须去覆盖基类的某个虚函数, 较好的做法是:__

  • A 在文档中声明,提醒程序员注意。

  • B 虚函数不实现,定义为纯虚函数的形式

  • C 虚函数后加 override 修饰

  • D 虚函数后加 final 修饰

2. 如何防止子类覆盖父类的虚函数?__

  • A 声明为const 类型的函数

  • B 声明为 override 类型的函数

  • C 声明为 final 类型的函数

  • D 声明为纯虚类型的函数

答案

1=>B 2=>C

接口

章节二:信息学竞赛 CPP 对象篇 - 图77

如何声明接口

接口在形式上,就是一个普通的类。

习惯上,所有成员函数都是public的纯虚函数,

不能被实例化。

与抽象类相比较,接口抽象程度更高,只提供约束,不真正实现任何东西。

也可以说:

接口只是一份契约,只是一份产品规格说明书。

c++中,并没有对接口和抽象类严格区分,惯例上:

  1. 只包含纯虚函数
  2. 不要定义成员数据,但可以有静态常量
  3. 定义虚析构函数
  4. 不要定义构造函数

示例

  1. struct IFlyable{
  2. virtual void fly() = 0;
  3. virtual ~IFlyable(){}
  4. };
  5. struct IManmade{
  6. virtual double price() = 0;
  7. virtual ~IManmade() {}
  8. };
  9. class Bird: public IFlyable{
  10. public:
  11. virtual void fly() override {
  12. cout << "I am a bird, flying..." << endl;
  13. }
  14. };
  15. class Plane: public IFlyable, public IManmade {
  16. public:
  17. virtual void fly() override {
  18. cout << "a plane can fly..." << endl;
  19. }
  20. virtual double price() {
  21. return 112345.6;
  22. }
  23. };
  24. void f(IFlyable& x)
  25. {
  26. cout << "beijing--->shanghai" << endl;
  27. x.fly();
  28. }
  29. void g(IManmade& x)
  30. {
  31. if(x.price() < 1000)
  32. cout << "cheap!" << endl;
  33. else
  34. cout << "expensive!" << endl;
  35. }
  36. int main()
  37. {
  38. Plane a;
  39. f(a);
  40. g(a);
  41. return 0;
  42. }

章节二:信息学竞赛 CPP 对象篇 - 图78

只用于标识的接口

不需要子类去实现什么,只是表明对象有某个性质,或属于某个类别。

比如,java中的 Serializable

  1. struct IMyTag
  2. virtual ~IMyTag() = 0; // 阻止创建实例,并不需要子类覆盖
  3. };
  4. IMyTag::~IMyTag(){} // 必须有实现,与普通函数override不同

实时效果反馈

1. 关于c++接口类型, 说法正确的是:__

  • A c++的接口是特殊的类,它的实例不占内存。

  • B c++把最抽象的类当作接口来使用

  • C c++的接口指的就是虚基类

  • D c++中,可以继承多个接口,但只能继承一个实体类。

2. 虚析构函数与普通的虚函数的多态有何不同?__

  • A 虚析构函数子类必须覆盖,否则编译不过

  • B 通过泛化指针删除对象时,先调用真实类的析构,然后自动依次调用祖先类析构。

  • C 先调用祖先类析构,再依次调用子孙类析构。

  • D 不能定义纯虚析构函数

答案

1=>B 2=>B

异常处理

image-20220130074516198

怎么对待错误?

尽可能早地,发现不正常的情况,避免更大损失。

  1. int fac(int n){
  2. if(n<=0) return 1; // 不好,这不是“健壮”,这是“姑息”
  3. ....
  4. }

某程序员曰:如果感觉不妙,就让它崩溃吧!

异常是管理错误的一种方式。

常规的管理方法:依赖返回值的检查。

有的场合不合适:

  1. 返回值位置被有效信息占用,没法再表达错误信息(带宽不够?)
  2. 多层的调用,层层检查,最高层才处理。
  3. 有些函数没有返回值,比如:构造函数,析构函数

c++异常机制

  1. try{
  2. if(...) throw 类型 x; //也可能是调用的函数链中throw了异常
  3. some_action() // 如果有异常,此后被跳过
  4. }
  5. catch(类型& e){
  6. // 处理此类异常
  7. }
  8. catch(类型& e){
  9. cout << e.what() << endl; // 打印异常原因
  10. }
  11. catch(...){
  12. // 所有未捕获类型
  13. }

代码中,通过 throw 抛出某个异常对象。

throw 后面可以是任何数据类型,甚至可以是 int double 类型

比如:throw -99; throw "bad index"; throw Point(2,5);

在其调用链的某处,用 catch 语句捕获。

异常的类型支持语言内置类型以及复合类型(可自定义)

与其迁就,不如崩溃!

应当精准捕捉错误,非预见的错误不要处理,暴露出来是好事。

异常的用途

  1. 报告错误

    有时,条件限制,我们无法通过返回值来报告错误,或意外发生的情况。

    (比如,返回值已经被其它值占用;

    函数要保持历史兼容,现在需要扩展功能;

    接口是别人定的,我们无法修改)

  2. 快速流程转移

    fa 调用 fb, fb 调用 fc …. fn 中发现错误,但,只有 fa 知道如何处理。

    image-20220129093812040

实例—自己定义数组类型

解决两个问题:

  1. 数组可能会很大,需要new, 但不要忘记了 delete

    1. int* ar = new int [1000*1000];
    2. ar[99] = ...
    3. ...
    4. if($%#$$@$@$) return; //很容易忘记“借钱”的事
    5. ...
    6. delete [] ar;
  1. 数组访问越界的情况,不能容忍,需要抛出异常,立即崩溃!
  1. class MyArray{
  2. public:
  3. MyArray(int size){
  4. pData = new int [size]; // 可能会引发异常
  5. this->size = size;
  6. }
  7. int& operator[] (int x){
  8. if(x<0 || x >=size) throw "bad index";
  9. return pData[x];
  10. }
  11. ~MyArray(){
  12. delete [] pData;
  13. }
  14. private:
  15. int* pData;
  16. int size;
  17. };

使用方法:

  1. try{
  2. MyArray a(10);
  3. a[5] = 555;
  4. cout << a[5] << endl;
  5. }
  6. catch(const char* e){
  7. cout << "ERR: " << e << endl;
  8. }
  9. catch(bad_array_new_length& e){
  10. cout << "ERR: " << e.what() << endl;
  11. }

catch之后

如果try中有分配资源,异常发生后,需要清理资源

当后续不知道如何处理,可以再次抛出异常: throw;

声明函数可能抛出的异常

  1. func(形参) throw(类型1, 类型2,...) {
  2. 函数体
  3. }

则,该函数仅能抛出异常类型1,类型2,及其子类型

如果一个函数保证不会抛出异常:

  1. func(形参) throw() { 函数体 }

如果什么都不写(一般就是这样),表示可能抛出任何异常。

实时效果反馈

1. 关于c++的异常, 说法正确的是:__

  • A 只能 throw 对象类型,不能throw 基本类型(如:int, char)

  • B 可能抛出异常的函数,必须在函数声明时加 throw 声明

  • C 在一个函数中抛出异常,如果它的调用链中没有catch,将导致程序退出

  • D 在本函数中抛出的异常,不能在本函数中catch

2. c++中,如何catch 到 try 块中抛出的所有异常__

  • A catch() { }

  • B cath(…){ }

  • C catch(exception& e){ }

  • D catch(any){ }

答案

1=>C 2=>B

标准库中的异常类

章节二:信息学竞赛 CPP 对象篇 - 图81

c++标准库中定义了异常体系,exception是所有异常的总根。

中,主要包含了:

  • logic_error: 主要是指逻辑上问题,这是可以避免的,属于程序设计上的漏洞

  • runtime_error: 运行时错,与用户输出有关,一般无法避免

比如:等待用户输入一个整数,结果,用户不小心输入了 “abc”

  • 还有些,语言一级的异常,比如:bad_alloc bad_typeid 等

new, delete, typeid 等内置的运算符会引发这些异常

image-20220128185621209

我们可以throw 这些类的实例,一般输入的参数是 const char*

一个定常字符串,描述异常的原因。

exception 类有个 what 是虚函数,子类都重写了它,返回 const char*,

表明本异常发生的原因,默认就是异常的类名字。

我们自己可以从exception 或它的子类的派生出自己的异常类型。

大型的项目一般有自己的多层级异常类型体系,这样便于将来的调试和运维。

常见异常类型含义

异常名称 含义说明
logic_error 逻辑错误,程序开发过程发现的不符合规约的异常, 一般可以修补掉
runtime_error 运行时错误,程序正常工作时产生的异常,与输入有关,一般没办法避免
bad_alloc 使用 new, new [] 分配内存时,引发的异常
bad_typeid typeid 输入参数为NULL,表观类型却是有虚函数的类指针
bad_cast dynamic_cast 动态转换失败
ios::base_failure io 操作过程中的异常
length_error 试图生成的对象,尺寸超限。
domain_error 参数的定义域错误,主要用于数学函数
out_of_range 超出有效范围
invalid_argument 参数无效
range_error 计算结果超出了有意义的范围
overflow_error 算术上溢
underflow_error 算术下溢

实例1

开发阶段中,就是要“小题大做”,“沾火就着”,对任何可疑之处,直接崩溃!

  1. long long fac(int x)
  2. {
  3. if(x<0) throw invalid_argument("factorial: x<0");
  4. if(x==0) return 1LL;
  5. return fac(x-1) * x;
  6. }

实例2

试着截获:new 分配异常(可能请求数量太大或太小)

  1. int* a = new int[-10];

catch 的时候,用什么类型

  1. try{
  2. int* a = new int[-10];
  3. }
  4. catch(bad_array_new_length& e){
  5. cout << e.what() << endl;
  6. }

也可以用它的祖先类:

bad_alloc, exception,

或者,直接 catch(…)

实时效果反馈

1. 下列哪个类的定义不在中:__

  • A logic_error

  • B runtime_error

  • C invalid_argument

  • D bad_alloc

2. 关于logicerror,说法==不正确==的是:_

  • A 一般是程序开发过程中暴露出的异常

  • B 一般可以通过修正程序而消除

  • C 依赖于用户输入的数据,很难去除该异常

  • D 如果所有主调链上的函数都不捕获该类异常,将导致程序异常中止。

答案

1=>D 2=>C

自定义异常类型

image-20220129213257057

为了更精细地表达异常信息,可以自己定义异常类型。

虽然,任何类的实例都可以作为 throw 的对象,但继承 exception或其派生类是惯例。

最常被继承的是:logic_error

自己定义的异常类型一般要覆盖 what() 虚函数,

以表明产生异常的原因。

如果,希望更详尽地描述异常信息,可以增加自己的数据,记录更丰富的信息。

比如,如果数组越界了,我们可能想知道,到底是什么样的错误索引,导致了越界。

一般,异常的处理层次没有那么多的时候,可以用struct,所有成员都public

自定义注意

  1. 覆盖 what() ,以表明原因
  2. 按惯例,有个传入const char* 的构造函数
  3. 预备今后的继承和多态用法,提供一个虚析构函数
  4. 为了正确地复制对象,提供拷贝构造和对象赋值。
  1. struct IndexError: logic_error{
  2. IndexError(int min, int max, int x):logic_error(""){
  3. sprintf(err,"index must between %d-%d, but get: %d",
  4. min, max, x);
  5. }
  6. virtual const char* what() const throw() override{
  7. return err;
  8. }
  9. char err[100];
  10. };
  11. class MyArray{
  12. public:
  13. MyArray(int size){
  14. pData = new int [size];
  15. this->size = size;
  16. }
  17. int& operator[] (int x){
  18. if(x<0 || x >=size) throw IndexError(0,size-1,x);
  19. return pData[x];
  20. }
  21. ~MyArray(){
  22. delete [] pData;
  23. }
  24. private:
  25. int* pData;
  26. int size;
  27. };
  28. int main()
  29. {
  30. try{
  31. MyArray a(20);
  32. a[9] = 555;
  33. cout << a[25] << endl;
  34. }
  35. catch(IndexError& e){
  36. cout << e.what() << endl;
  37. }
  38. }

exception及其大部分子类,带参构造(const char*),填入的信息就是what() 返回的信息。

如果希望更丰富细腻的信息,一般 2 种方案:

  1. 如前边示例,自己预留空间,计算并保存异常信息,然后重载what(),显示信息。

  2. 可以利用父类构造函数传入一般的异常信息,更详尽参数通过定义成员函数获得:

    比如:get_min_index( ),get_max_index( ),get_index( )

实时效果反馈

1. 关于c++异常的说法,正确的是:__

  • A 自定义的异常类型必须继承自 exception

  • B 自定义的异常必须提供无参的构造函数

  • C 自定义异常类没有任何强制约束,但继承自exception或其子类是惯例

  • D 自定义的异常类必须覆盖 what() 虚函数

2. 自定义异常类要覆盖 exception 的 what虚函数,正确签名是:__

  • A const char* what() const;

  • B char* what() throw();

  • C virtual const char* what() const throw();

  • D char* what() override;

答案

1=>C 2=>C

RAII

image-20220129211454145

c++ 标准支持try…catch 结构,却没有提供对 try..finally 的支持。

  1. // Java 的模板代码
  2. x = 分配资源;
  3. try{
  4. 使用资源。。。。。;
  5. ...return xx;
  6. ...break;
  7. ...throw 异常;
  8. ...正常。。
  9. }
  10. finally{
  11. 释放资源(x);
  12. }

有些编译环境扩展了标准c++,比如微软的VS,提供了相应的TRY…FINALLY结构。

标准c++鼓励使用对象的析构函数来回收资源,以防止资源损失

(不仅仅是内存,还有文件句柄,socket等)

RAII = Resource Acquisition Is Initialization “资源获取即初始化”

利用对象的析构自动执行的优势,实现资源的自动化管理。

广义地说,资源也包括任何必须成对儿出现的动作。

比如,某规范约定,

开始,需要调用 hello()

结束,需要调用bye()

关键问题:如何保证,只要开始了,任何复杂局面下,

这个bye()都一定会获得执行的机会。

常规手段的弱点

  1. a = 分配资源();
  2. ...
  3. if(出错) return; // 这里忘记了回收资源
  4. 释放资源(a);
  1. for(int i=0; i<=end; i++){
  2. if(i==0) 分配资源();
  3. if(出错) break; // 忘记了回收资源
  4. if(i==end) 回收资源();
  5. }
  1. 分配资源();
  2. ...
  3. if(出错) throw MyErr("#$@$#%@"); // 忘记了回收资源
  4. ...
  5. 回收资源();

RAII 的威力

  1. struct A{
  2. A() { cout << "acquire resource..." << endl; }
  3. ~A() { cout << "free resource...." << endl; }
  4. // 资源指针
  5. };
  6. void f()
  7. {
  8. cout << "begin..." << endl;
  9. A a;
  10. throw "bad argument";
  11. cout << "after exception.." << endl;
  12. } // 此处调用析构
  13. int main()
  14. {
  15. try{
  16. f();
  17. }
  18. catch(...){ }
  19. return 0;
  20. }

可以看到,当栈对象所在函数throw了异常以后,析构函数仍能正确调用,

这是资源能够正确释放的必要保证。

有c++建议认为:程序中,应该尽量避免硬编码的:delete 语句,

尽量使用RAII来完成资源的自动回收。

这种风格,可以取得 try…finally 的效果,并且有更高的执行效率。

智能指针就是RAII 策略的典范。

充分利用对象的构造和析构函数,完成配对儿特征的操作。

实时效果反馈

1. 在栈上创建的对象,当离开其作用域时,说法正确的是:__

  • A 当因为异常而离开作用域,不会自动调用析构函数

  • B 当因为抛出了用户自定义的异常而离开作用域,不会自动调用析构函数

  • C 当因为抛出了runtime_error 而离开作用域,不会自动调用析构函数

  • D 只要抛出的异常被任何层次的调用者捕获,析构总是会执行。

2. RAII方案的主要用处是:__

  • A 正确初始化资源,避免浪费

  • B 确保对象离开作用域时,自动归还资源,以防止资源耗尽

  • C 主要用于文件打开后,确保关闭。但不能避免堆内存的泄漏。

  • D 主要用于配合异常机制使用,保证异常的正确捕获

答案

1=>D 2=>B

image-20220211084101255

栈是具有后进先出(LIFO)特征的线性结构。

栈的具体实现,可以是数组,动态数组,链表,块链等,视需求而定。

为了隔离实现层,我们可以设计出栈的接口

这里还要考虑个问题:

我们并不知道用户会往栈中添加什么数据,怎样尽量兼容广泛的数据类型呢?

我们可以再做个接口,让用户的类型从它继承:

  • 元素统一类型的接口

    1. struct IObj{
    2. virtual ~IObj(){}
    3. virtual void show(ostream& os) const = 0;
    4. };
    5. ostream& operator<<(ostream& os, IObj* p)
    6. {
    7. p->show(os);
    8. return os;
    9. }
  • 栈的接口

    1. struct IStack{
    2. virtual ~IStack() {}
    3. virtual IStack& push(IObj*) = 0;
    4. virtual IObj* pop() = 0;
    5. virtual bool empty() = 0;
    6. };

    这个接口与实现方式无关。

    它指出:

    栈必须能支持 pop, push 操作,能判断是否为空栈。

我们用最简单的数组来实现栈:

  1. class MyStack:public IStack{
  2. public:
  3. MyStack() { n = 0; }
  4. virtual MyStack& push(IObj* x) override{
  5. if(n>=100) throw "stack overflow";
  6. data[n++] = x;
  7. return *this;
  8. }
  9. virtual IObj* pop() override{
  10. if(n==0) throw "empty pop!";
  11. return data[--n];
  12. }
  13. virtual bool empty() override{
  14. return n == 0;
  15. }
  16. private:
  17. IObj* data[100];
  18. int n;
  19. };

然后,定义一个用户自己的具体元素类型。

以point为例:

  1. struct Point: public IObj{
  2. Point():Point(0,0){}
  3. Point(int x, int y){this->x=x; this->y=y;}
  4. virtual void show(ostream& os) const override{
  5. os << "(" << x << "," << y << ")";
  6. }
  7. int x;
  8. int y;
  9. };

下面是使用 MyStack的方式。

注意:

元素的内存需要使用者来管理,MyStack 类内存储的是指针,它没有管理元素内存的责任。当然,这只是一种设计。我们也可以设计为:让MyStack在析构的时候,负责释放指针指向的空间。

  1. // 面向接口编程,不了解底层实现方式
  2. void clear(IStack& s)
  3. {
  4. while(!s.empty()){
  5. cout << s.pop() << endl;
  6. }
  7. }
  8. int main()
  9. {
  10. MyStack a;
  11. Point p1 = Point(1,1);
  12. Point* p2 = new Point(2,2);
  13. Point* p3 = new Point(); // = Point(0,0)
  14. a.push(&p1).push(p2).push(p3);
  15. clear(a);
  16. delete p2;
  17. delete p3;
  18. return 0;
  19. }

实时效果反馈

1. 关于栈, 说法正确的是:__

  • A 栈中的元素必须是顺序存储,不能是链式存储

  • B 栈中的元素必须为指针类型

  • C 弹栈时,如果栈为空,返回NULL指针

  • D 栈是逻辑上的接口,它与具体的实现方式无关

2. 接口为什么要包含虚的析构函数?__

  • A 如果不这样,接口就会有可能实例化。

  • B 使用泛化的接口工作时,当接口对象离开作用域,会调用最恰当的析构函数

  • C 阻止子类重写析构函数

  • D 为了与虚的构造函数对应

答案

1=>D 2=>B

栈的链式实现

章节二:信息学竞赛 CPP 对象篇 - 图86

栈只是接口,并没有规定实现方式。

当用数组实现时,有个最大的缺点:栈的最大容量被固定死了。

如果用链表实现,就可以无限扩张了(还是要受限于物理内存)

设计

其它部分基本不动,只是对MyStack的实现重新设计:

这里需要一个 Node 对象,但这个对象并不需要外界知晓,可以作为private内部类

工作中的图景如下:

image-20220210105316344

LinkStack中只有一个head,

它平常指向栈顶的 Node 节点,Node间链接,data 指向用户的数据

栈只负责维护自己的动态 Node 内存,不负责用户数据的内存管理。

实现代码

  1. class LinkStack:public IStack{
  2. public:
  3. LinkStack() { head = NULL; }
  4. virtual LinkStack& push(IObj* x) override{
  5. head = new Node{x, head};
  6. return *this;
  7. }
  8. virtual IObj* pop() override{
  9. if(head==NULL) throw "empty pop!";
  10. Node* t = head;
  11. IObj* rt = t->data;
  12. head = head->next;
  13. delete t;
  14. return rt;
  15. }
  16. virtual bool empty() override{
  17. return head==NULL;
  18. }
  19. private:
  20. struct Node{
  21. IObj* data;
  22. Node* next;
  23. };
  24. private:
  25. Node* head;
  26. };
  27. struct Point: public IObj{
  28. Point():Point(0,0){}
  29. Point(int x, int y){this->x=x; this->y=y;}
  30. virtual void show(ostream& os) const override{
  31. os << "(" << x << "," << y << ")";
  32. }
  33. int x;
  34. int y;
  35. };

实时效果反馈

1. 栈的链表实现,相比于数组实现,说法正确的是:__

  • A 存储元素的数量几乎没有限制

  • B 运算速度更快,更灵活

  • C 不会抛出异常来

  • D 能自动释放堆空间上分配的用户数据

2. 为什么对同一接口,经常提供多个版本的具体实现,最可能的原因是:__

  • A 不同的场合有不同要求或约束条件,一个实现很难满足所有的应用场景

  • B 随着时间的推移,人们发现原来的实现有bug,提出了更好的方案

  • C 不同的公司因为版权问题,不得不给出自己实现版本

  • D 原来的程序员离职,源码看不懂,还不如重写一个实现

答案

1=>A 2=>A

栈的块链实现

image-20220212184111464

栈的链表实现解决了元素个数限制的问题,

但,它在效率上有很大的牺牲。如何能更高效呢?

可以把链表和数组的优点结合起来使用,这就是块链。

设计

大块的数组间用单链表链接。

这样不会在每次 push的时候都去 new 新块。一个块满员后,才new 新块。

同理,一个块空时,把该块删除。

image-20220210115953997

实现

  1. class BlockStack:public IStack{
  2. public:
  3. BlockStack() { head = NULL; }
  4. ~BlockStack() { while(!empty()) delete pop(); }
  5. virtual BlockStack& push(IObj* x) override{
  6. if(head==NULL || head->n==BS) {
  7. Node* t = new Node;
  8. t->next = head;
  9. head = t;
  10. }
  11. head->data[head->n++] = x;
  12. return *this;
  13. }
  14. virtual IObj* pop() override{
  15. if(head==NULL) throw "empty pop!";
  16. IObj* rt = head->data[--head->n];
  17. if(head->n==0){
  18. Node* t = head;
  19. head = t->next;
  20. delete t;
  21. }
  22. return rt;
  23. }
  24. virtual bool empty() override{
  25. return head==NULL;
  26. }
  27. private:
  28. static const int BS=5; //这是外部类的 private, 内部类可用
  29. struct Node{
  30. Node(){ n=0; next=NULL; }
  31. IObj* data[BS];
  32. int n;
  33. Node* next;
  34. };
  35. private:
  36. Node* head;
  37. };

实时效果反馈

1. 关于块链, 说法正确的是:__

  • A 块链就是动态数组

  • B 块链是分块的数组,分块的数组间通过链表连接

  • C 块链的操作效率高于数组

  • D 块链比单链表更节约空间

2. 关于内部类,说法正确的是:__

  • A 内部类可以访问外部类的私有成员

  • B 外部类可以访问内部类的私有成员

  • C 创建外部类的实例,会隐含创建内部类实例

  • D 内部类实例必须依赖外部类实例存在,不能单独存在

答案

1=>B 2=>A

括号匹配问题

image-20220212184559264

问题

给定一个串,中间可能出现各种括号 (, ), [,],{,}

写一个函数,判断串中的括号是否匹配。

例如: ..(.[.]..(.).). {..}... 为匹配

....(...[..)..]...{..}... 为失配

....(..(...[...]..)..... 也为失配

再一个失配的例子:.....)..[.{.}..[..]..].....

分析

这个问题与迷宫冒险类似。

比如一个迷宫,有很多带唯一标号的门。如果来时,我们走的门为:1,2,3

则,出去时,应该走的门是:3,2,1。

这就是:沿着原路返回的原则,就是:FILO的原则。

左括号看作一个门的入口,右括号看作一个门的出口。

左括号压栈,右括号弹栈。弹栈时必须与压栈时的括号配对儿。

实现

  1. class Stack{
  2. public:
  3. Stack() { n = 0; }
  4. void push(char x) { data[n++] = x; }
  5. char pop() {
  6. if(empty()) throw -1;
  7. return data[--n];
  8. }
  9. bool empty() { return n==0; }
  10. private:
  11. char data[100];
  12. int n;
  13. };
  14. bool good(const char* s)
  15. {
  16. Stack a;
  17. try{
  18. while(*s){
  19. if(*s=='(') a.push(')');
  20. if(*s=='[') a.push(']');
  21. if(*s=='{') a.push('}');
  22. if(*s==')' || *s==']' || *s=='}'){
  23. if(a.pop() != *s) return false;
  24. }
  25. s++;
  26. }
  27. }
  28. catch(int e){
  29. return false;
  30. }
  31. return a.empty();
  32. }

实时效果反馈

1. 关于FILO设计的用途, 说法正确的是:__

  • A 为了重复使用,节约内存

  • B 为了实现“沿着原路返回去”

  • C 为了使后来的元素有优先权

  • D 为了避免栈溢出

2. 关于catch语句,说法正确的是:__

  • A catch 只能抓住对象类型,不能抓住原生类型。

  • B catch 只能抓住指针类型,不能抓住对象类型。

  • C catch 只能抓住引用类型,不能抓住指针类型。

  • D catch 可以抓住任何 throw 的类型。

答案

1=>B 2=>D

循环队列

image-20220212194907807

队列是一种抽象,可以实现为接口。

与栈一样,队列也是一种线性结构

的特征是:后进先出。并且,只能在它的一个端头进行操作。

队列的特征是:先进先出。

并且,只能在它的两个端头进行操作。一个端头入队,一个端头出队。

用数组实现队列

最简单常见的场景,可以用数组来实现队列。

数组的大小是固定的,决定了这个队列的最大容量。

容易想到的方案:

image-20220212200730659

这里需要解决的问题是:如何充分利用数组。

因为,front指针走过的位置荒废可惜了,如何重复利用?

可以把数组看成一个环状的缓冲区

解决方案

  1. class MyQue{
  2. public:
  3. MyQue(){ rear=0; front=0; }
  4. MyQue& enque(int x){
  5. if((rear + 1) % N == front) throw -1;
  6. buf[rear] = x;
  7. rear = (rear + 1) % N;
  8. return *this;
  9. }
  10. int deque(){
  11. if(empty()) throw -2;
  12. int rt = buf[front];
  13. front = (front + 1) % N;
  14. return rt;
  15. }
  16. bool empty(){ return front==rear; }
  17. private:
  18. static const int N = 5;
  19. int buf[N];
  20. int rear;
  21. int front;
  22. };

测试一下:

  1. MyQue a;
  2. a.enque(1).enque(2).enque(3).enque(4);
  3. while(!a.empty()) cout << a.deque() << endl;

实时效果反馈

1. 关于队列与栈的共性, 说法正确的是:__

  • A 都是后进先出

  • B 都是用数组来实现的

  • C 都是线性结构

  • D 都是网状结构

2. 用容量为N的数组实现循环队列,rear, front分别是即将入列,出列位置,则:__

  • A front == rear 表示队列满

  • B rear + 1 == front 表示队列满

  • C (rear + 1) % N == front 表示队列满

  • D front + 1 == rear 表示队列满

答案

1=>C 2=>C

STL的string

章节二:信息学竞赛 CPP 对象篇 - 图93

c++标准库中的string类非常强大,是面向对象设计的典范。

合理使用,可以极大提高编程效率。其设计思想对我们设计自己的类很有借鉴意义。

可参考API在线文档:

https://www.cplusplus.com/reference/string/

string 类的成员函数概览

函数名称 功能 说明
swap 交换两个字符串的内容
+=, append, push_back 尾部添加 push_back配合pop_back, 类栈使用
insert 任意位置插入
size, length 返回字符数量
erase, clear 删除
replace 替换
empty 是否为空串
capacity 重新分配之前的容量 与size不同,还包含未使用的
reserve 提前分内存,以保证capacity
[], at 存取字符 at 会检查越界,抛出out_of_range
+ 拼接 可以与 const char* 混合
==, !=, <, <=, >, >=, compare 比较 compare 提供部分比较
>>, getline 从stream中读取 getline遇到空格不会停止
copy 复制到char* buf 自己准备足够空间
c_str 缓冲区作为c-string 只读
data 缓冲区作为char数组 只读
substr 返回子串
find,find_first_of, … 查找子串
begin, end 正向迭代器
rbegin, rend 逆向迭代器

构造方式

  1. string s1; // 空串
  2. string s2("1234567"); // 从 c-string 拷贝构造
  3. string s3(s2); // 拷贝构造
  4. string s4(s2,3); // s2 中从下标3开始
  5. string s5(s2,3,2); // s2 中从下标3开始,2个字符
  6. string s6(4,'x'); // 4 个 'x'
  7. string s7(s2.end()-3,s2.end()); // 迭代器区间,最后3个char

输入,输出

输出简单。

输入注意:cin >> s, 遇到空格即认为输入结束,需要输入含有空格的串,可以用 getline

  1. string s;
  2. //cin >> s;
  3. getline(cin, s);
  4. cout << s << endl;

查找

  1. string a = "12345671234567";
  2. cout << a.find("56") << endl;
  3. cout << a.find("58") << endl; // 返回 string::npos
  4. // static const size_t npos = -1;
  5. cout << a.find("56",7) << endl; // 从 7 位置开始往后找

注意,判断返回值是否查找成功要与 string::npos比较,不要写-1

find_first_of find_first_not_of … 任意一个字符匹配

遍历

  1. string s = "abcdefg";
  2. string::iterator i = s.begin();
  3. while(i!=s.end()){
  4. cout << *i << endl;
  5. i++;
  6. }

c++11 支持类型自动推断,可以用 auto 代替 string::iterator

  1. string s = "abcdefg";
  2. for(auto i=s.rbegin(); i!=s.rend(); i++){
  3. cout << *i << endl;
  4. }

实时效果反馈

1. string s1(“abc”); char* s2=”123”; 下列哪个操作会产生编译错误?:__

  • A s1 + s2

  • B s2 + s1

  • C s1 += s2

  • D s2 += s1

2. string中使用迭代器遍历,而不是指针遍历的主要原因是:__

  • A 效率比指针高

  • B 更安全,不会出现越界等现象

  • C 更高的抽象,与string内部实现的内存模型无关

  • D 更好的兼容性

答案

1=>D 2=>C

STL的string(2)

image-20220217141153067

一般的开发实践中,我们首先最关心的问题是:

string 与 char* 类型如何完美互操作,相互转换。

string 比 char* 的主要优势在于:自动管理动态内存,不容易产生:

  • 因忘记释放堆内存而内存泄漏
  • 因分配空间不足,而产生使用越界

比如,传统上,返回串是件很尴尬的事情。

一般,两种设计:

  1. char* f(...) // f函数动态申请,使用f的客户很容易忘记是释放
  2. void f(char* buf, ...) // 客户方提供内容,f写入,容易超安全范围使用

如果返回 string 对象就没有这些问题。

string 如何转为 char*

image-20220217110646356 len有两个作用:

  1. 不想全部拷贝,拷贝源串的一部分
  2. 做个最大限定,防止冲出去,破坏其它内存对象
  1. string s = "12345";
  2. char buf[100];
  3. buf[s.copy(buf,99)] = '\0';
  4. cout << buf << endl;

如果,只是读取,并不改写,不必要复制一份内存。

  1. string s = "123456";
  2. const char* s1 = s.c_str();
  3. const char* s2 = s.data(); // c++99不保证有'\0'结束
  4. cout << s1 << endl;
  5. cout << s1 << endl;

整数与string的互转

  • 可以使用 cstdlib(stdlib.h) 中的 aoti
  1. string s = "123";
  2. int a = atoi(s.c_str());
  3. cout << a << endl;
  • 也可以使用更一般的方案,对任何对象转换,更灵活有弹性

需要:#include <sstream>

  1. string s = "123";
  2. stringstream ss; // 操作类似于 cin, cout,只是针对串
  3. int a;
  4. ss << s;
  5. ss >> a;
  6. cout << a << endl;
  • c++11 提供了更实用的解决方案
  1. string s1 = "1234";
  2. string s2 = "ff";
  3. string s3 = "1010";
  4. string s4 = "0x7f";
  5. cout << stoi(s1) << endl;
  6. cout << stoi(s2,NULL,16) << endl;
  7. cout << stoi(s3,NULL,2) << endl;
  8. cout << stoi(s4,NULL,0) << endl;

类似地,可以实现浮点数的转换:

image-20220217122017212

  • 整数转为string, char*

可以传统的 itoa

  1. char buf[100];
  2. itoa(255,buf,16);
  3. cout << buf << endl;

传统的 sprintf

  1. char buf[100];
  2. sprintf(buf, "%04x", 255);
  3. cout << buf << endl; // 00ff

流式的stringstream

  1. stringstream ss;
  2. string s;
  3. ss << hex << 255;
  4. ss >> s;
  5. cout << s << endl;

也可以达到 sprintf 的控制效果,详见cin,cout一节

c++11 提供的 to_string

  1. string s = to_string(255);
  2. cout << s << endl;

不能精确控制,但简单、方便。

实时效果反馈

1. s是string类型,下列哪个特性是c++11的标准:__

  • A atoi

  • B stoi

  • C s.c_str()

  • D s += “123”

答案

1=>B

std::string 应用示例

image-20220217141705778

STL 的string 类提供了串的最基本的操作集合(最小集合)

我们可以扩展它,实现更丰富的功能

split 操作

一个或多个空格为分隔符,把一个串分割为多个部分。

  1. string s = " abc 1234 xyz kkkk ";
  2. int p1 = 0;
  3. while(1){
  4. int p2 = s.find(" ", p1);
  5. if(p2 == string::npos) {
  6. cout << s.substr(p1) << endl;
  7. break;
  8. }
  9. string s1 = s.substr(p1, p2-p1);
  10. if(!s1.empty()) cout << s1 << endl;
  11. p1 = s.find_first_not_of(" ", p2);
  12. if(p1 == string::npos) break;
  13. }

这个方案,逻辑较多,很多细腻的控制,容易带入bug。

其实, (string.h) 提供了 strtok 来解决这个常见的问题

  1. char buf[100] = " abcd xyz 1234 ";
  2. char* p = strtok(buf, " ");
  3. while(p){
  4. cout << p << endl;
  5. p = strtok(NULL, " ");
  6. }

这个解决方案很值得借鉴。

一般的高级语言,都会返回一个数组类型,c++为了效率,没有这样做。

配合static保存上一次的结果,可以方便地进行对结果的遍历访问。

trim 操作

去掉串的首尾空格

  1. string s = " abcd ";
  2. s.erase(0, s.find_first_not_of(" "));
  3. s.erase(s.find_last_not_of(" ")+1);
  4. cout << s << endl;
  5. cout << s.length() << endl;

需要仔细考虑找不到的情况,比如源串为空串。

string再封装

  1. class mystr: public string{
  2. public:
  3. using string::string;
  4. void trim(){
  5. erase(0, find_first_not_of(" "));
  6. erase(find_last_not_of(" ")+1);
  7. }
  8. };

注意: using 一句,这是c++11 引入的方便,否则,需要显式地进行构造函数透传。

因为,string的构造函数特别多样,透传是很头疼的事情。

使用方法:

  1. mystr s(" abcd ");
  2. s.trim();
  3. cout << s << endl;
  4. cout << s.length() << endl;

实时效果反馈

1. string类的findfirstof 与find的区别, 说法正确的是:__

  • A find_first_of 可以指定从哪里开始搜索

  • B find_first_of 可以指定搜索一个字符,而不是串

  • C find_first_of 找不到匹配时,会throw异常

  • D find_first_of 给定匹配串时,不是匹配整个序列,而是其中任意字符

2. 按c++11标准, 继承了父类A后,怎样透传它的构造函数:__

  • A 不做任何动作,默认透传

  • B 添加一句:using A::A;

  • C 必须把父类的每个构造形式重新定义一遍,利用初始化列表透传

  • D 增加编译指令

答案

1=>D 2=>B

标准库的cin,cout

image-20220217204643064

cin 标准输入流,默认指键盘,可以重定向。

cout 标准输出流,默认指显示终端,可以重定向。

一般要重载两个运算符:

<< 插入运算符,把对象序列化到流中。

>> 提取运算符,把流中的东西重建为对象。

惯例上,这两个运算符都返回流对象的引用,以便于连续书写。

  1. string name;
  2. int age;
  3. cout << "please input name and age: ";
  4. cin >> name >> age;
  5. cout << name << ":" << age << endl;

注意,每个提取项默认都是空白字符分割(空格,tab, 回车)

如果希望串中含有分割符,就需要getline

cin 有几个bool函数判断当前内部状态:

  1. bool good(); // true 表示一切正常
  2. bool eof(); // true 表示达到了流的末尾,比如文件结束
  3. bool fail(); // true 表示遇到非法数据,可以恢复
  4. bool bad(); // true 发生了物理上的致命错误,流无法继续使用

示例,输入一个整型数,直到正确为止:

  1. int a;
  2. while(1){
  3. cin >> a;
  4. if(cin.good()) break;
  5. cout << "err data, input again!" << endl;
  6. cin.clear(); // 清空状态标志
  7. while(cin.get()!='\n'); // 清空缓冲区
  8. }
  9. cout << "a: " << a << endl;

cout的格式控制

cout 也可以像 printf 那样仔细控制输出格式。

  1. printf("%010.3f", 3.1415926); // 000003.142

可以看到精细的控制点:

  • 总宽度
  • 小数精度
  • 左右对齐方式
  • 不足位置补的字符(默认是空格,可以改为0,#等)

cout 也有这个控制能力,需要头文件:

#include <iomanip>

  1. double a = 3.1415926;
  2. cout << a << endl;
  3. cout << fixed // 强制以小数方式显示
  4. << setprecision(3) // 设置小数显示精度
  5. << a << endl;
  6. cout << setw(10) // 显示宽度
  7. << left
  8. << setfill('#') // 填充字符
  9. << a << endl;

流是统一的概念

我们介绍的流操作可以适用于:

cin, cout

文件I/O流

stringstream 等

这极大地简化了概念和互操作性。

image-20220430072839564

istream: 是用于输入的流类,cin就是该类的对象。 ostream: 是用于输出的流类,cout就是该类的对象。 ifstream: 是用于从文件读取数据的类。 ofstream: 是用与向文件写入数据的类。

stringstream: 用于向串流对象中写入或读取。

比如,文件的读入,与从cin读入没有什么区别:

需要#include <fstream>

  1. ifstream in("d:\\1.txt");
  2. if(!in) {
  3. cout << "no such file!" << endl;
  4. return -1;
  5. };
  6. char buf[100];
  7. while(in.getline(buf, 80)){
  8. cout << buf << endl;
  9. }

实时效果反馈

1. string s; cin >> s; 说法正确的是:__

  • A 从键盘读入串,直到回车前所有字符,存入s

  • B 从键盘读入串,直到空格前所有字符,存入s

  • C 从键盘读入串,直到分隔符(空格,tab, 回车),存入s

  • D 从键盘读入串,直到 EOF 符

2. 关于cout 格式控制,说法==错误==的是:__

  • A 可能需要头文件:<iomanip>

  • B cout << fixed; 强制用小数方式显示

  • C setw(int n) 能控制显示宽度

  • D setw(int n) 对该调用后的所有输出都有效

答案

1=>C 2=>D

标准输入输出的重定向

image-20220221190514286

cin, cout 与 文件流、字符串流一样,都是广义上的流,当面向高层概念编程时,底层的类型之间可以偷偷换一下。

所以,标准的输入输出可以重定向。

举例来说:原来从键盘输入,可以改为从某个准备好的字符串流输入。

原来是输出到屏幕,可以重定向到某个固定的文件里。

场景

最常见的应用场景:在线的 OJ 网站,可以对我们提交的源代码测评。

用很多测试用例灌入程序,看它的输出结果。

这就需要把输入,输出都定向为文件。

可以在 leetcode 刷题上体会下。

重定向法一:

c 语言中原来常用的做法

把屏幕重定向到文件:

  1. freopen("d:\\out.txt", "w", stdout);
  2. cout << "haha" << endl;
  3. cout << 123 << endl;

把键盘重定向到文件:

  1. freopen("d:\\in.txt", "r", stdin);
  2. string s;
  3. while(getline(cin, s)){
  4. cout << s << endl;
  5. }

重定向法二:

如果希望使用更加 c++ 的风格,可以用 rdbuf() 函数

rdbuf 定义在 中,当然被 istream, ostream 继承了。

有两种重载的格式:

  1. streambuf * rdbuf() const; // 返回当前的缓冲区
  2. streambuf * rdbuf(streambuf * sb); // 设置新的缓冲区

下面示例,把 cin 从键盘改为从 stringstream 中输入。 需要include

  1. stringstream ss;
  2. ss << "1 2 3" << "\n";
  3. ss << "4 5";
  4. streambuf* old = cin.rdbuf(ss.rdbuf());
  5. int a = 0;
  6. int t;
  7. while(cin >> t) a += t;
  8. cout << a << endl;
  9. cin.rdbuf(old);

模仿 cin, 也可以 对 cout 这样重定向。

重定向法三:

与语言无关,可以在控制台上用命令行来重定向:

章节二:信息学竞赛 CPP 对象篇 - 图101

这样,本来输出到屏幕的内容就重定向到文件中了。

类似地,也可以把从键盘读入的内容,转而从某个准备好的文件读入:

xx.exe < in.txt

注意:

xx.exe 文件的位置,不是和源代码在一起的,在qt creator 生成一个很长名字的文件夹里, 默认在 debug 文件夹下。

有可能运行时,会弹出 libgcc_s_dw2-1.dll 找不到错误,这是因为相应的动态库没有在path路径中,需要在环境变量里加入:

类似如下的路径:

C:\Qt\Qt5.9.5\Tools\mingw530_32\bin

根据自己的实际安装位置添加。

实时效果反馈

1. 关于输入输出重定向, 说法正确的是:__

  • A 只能重定向到文件中

  • B 只能重定向到字符串流中

  • C 只能重定向到空设备

  • D 可以重定向到任何流设备

2. 输入重定向最常见的应用场合是:__

  • A 用固定的输入数据测试一个程序是否工作正常

  • B 键盘发生故障,用文件代替

  • C 有些符号键盘打不出来,用文件代替

  • D 在不同的操作系统下,键盘格式不同,用文件可以统一

答案

1=>D 2=>A