- 信息学竞赛 CPP 对象篇
- 面向对象概观
- 类与对象
- 对象与指针
- 指针赋值与对象拷贝
- 浅拷贝与深拷贝
- 成员函数与this指针
- 构造子与析构子
- 构造函数重载
- 对象的生存期
- 对象的传递
- 静态成员函数
- 静态成员变量
- 对象的状态
- 对象的状态(2)
- 对象内存结构
- 对象内存结构(2)
- 拷贝构造
- 赋值函数
- 友元函数
- 友元类
- 内部类
- 运算符重载
- 重载运算符基本规则
- 特殊运算符的重载
- const修饰符
- 成员对象与封闭类
- 智能指针之引用计数
- 日期类型
- 日期类型(2)
- 有理数类
- 字符串类
- 基本设计
- 实现思路
- 字符串类(2)
- 继承
- 继承后的权限
- 多继承与二义性
- 继承中的构造函数
- 继承下的内存模型
- 指针的泛化
- 多态
- 虚函数表
- 虚析构函数
- RTTI机制
- 抽象类型
- 接口
- 异常处理
- 标准库中的异常类
- 自定义异常类型
- RAII
- 栈
- 栈的链式实现
- 栈的块链实现
- 括号匹配问题
- 循环队列
- STL的string
- STL的string(2)
- std::string 应用示例
- 标准库的cin,cout
- 标准输入输出的重定向
信息学竞赛 CPP 对象篇
面向对象概观
- 最早的面向对象语言 simula67
- 影响深远的 smalltalk(squeak/phora),lisp
- 中规中矩的 Java / c#
- 灵活易用的 python / ruby
- 原型路线的 javascript / lua
面向过程、面向对象思路上的差异
面对一个问题时,
面向过程: 第一步做什么?然后再做什么?
面向对象:
- 应该有哪些实体一起来完成这个任务?
- 这些实体可以归为哪几个类型?
- 每个实体应该具有什么样的功能?
- 实体间该如何交换信息?
面向对象的思想是:模拟客观世界中的事物,来构造软件系统。
为什么要引入面向对象?
- 如何增强软件的重用性?
- 基本的拷贝/粘贴?
- 模式上的重复,提取为函数?
- 例如,排序算法如何抽象出来,以供重用?
- 软件的可维护性
- 可读性。 文档。实体间的联系。
- 可修改性,可测试性
- 可以只扩充,不修改吗?
- 用户的需求模糊,且不断变更,不断演化
- 过程化的自顶向下很难应付
- 对象模型是自底向上的思想
面向对象的特征
- 抽象
- 封装性。隐藏细节、结构、实现方式
- 继承。 c++支持多重继承
- 多态。 面向对象的精髓所在。
面向对象与基于对象
基于对象的语言,焦点在:使用对象。体现封装性。
有些无法派生出用户的“新类型”,当然就没法多态了。
有些不支持动态多态。当然,这样好处是:轻量级。注重效率,避免复杂化。
golang
面向对象关联
OOA,OOD,OOP
《UML》 统一建模语言,统一了Booch, OMT, OOSE 的表示方法,并扩展
工程标准,必学。
课程用到了:类图,对象图,活动图,时序图等。
类图示例
时序图示例
《设计模式》 23个经典模式及其衍生。对如何抽象和剥离有重要指导意义。
课程中会有部分涉及。
实时效果反馈
1. 关于面向对象编程语言,说法正确的是:__
A C++是纯面向对象语言,不能面向过程编程。
B c++是单继承模式,不能继承多个父类型。
C c++使用基于原型(prototype)的对象模型
D c++支持封装,继承,多态的面向对象特征
2. 相比于面向过程,使用对象的优势:__
A 代码执行效率高,运行更快。
B 更节省内存空间,适合于嵌入式环境
C 大型工程项目更易于管理和维护,可重用性更好。
D 很容易跨平台,可移植性更好。
答案
1=>D 2=>C
类与对象
类是图纸,是蓝图,对象是实体,是房屋。
类不在内存。对象在内存。
通过类可以创建对象。
通过1个类,可以创建多个对象。 1 : n
类的语法
class 类名 {
访问权限修饰符(private, public,protected):
成员数据;
成员函数;
};
class 是 struct 升级版
c++统一了class与struct,用struct定义类也可以,区别是:默认为public修饰符
成员变量,成员函数
也称为:
属性(property),方法(method)
类的定义,体现了封装性。
类实现了:功能与实现的分离,实现了功能与数据结构的分离。
【示例】定时器
功能:
- 设置倒计时:分,秒
- tick() 走 1 秒
- 读取当前剩余:分,秒
// 定时器的定义
class Timer
{
public:
void set(int min, int sec);
void tick();
int get_min();
int get_sec();
private:
int min;
int sec;
};
////// 定时器的实现
void Timer::set(int m, int s)
{
min = m;
sec = s;
}
// 走1秒
void Timer::tick()
{
if(sec>0){
sec--;
}
else if(min>0){
min--;
sec = 59;
}
if(min==0 && sec==0)
cout << "beep ... beep ... " << endl;
}
// 读取时间
int Timer::get_min() { return min; }
int Timer::get_sec() { return sec; }
使用方法:
Timer a;
a.set(1,15);
a.tick();
cout << a.get_min() << "," << a.get_sec() << endl;
for(int i=0; i<80; i++) a.tick();
当我们改变Timer实现机制时,只要功能不变,并不需要通知调用方。
// 定时器
class Timer
{
public:
void set(int min, int sec);
void tick();
int get_min();
int get_sec();
private:
int sec;
};
/////// 实现
void Timer::set(int m, int s)
{
sec = m * 60 + s;
}
// 走表
void Timer::tick()
{
if(sec>0) sec--;
if(sec==0)
cout << "beep ... beep ... " << endl;
}
// 读数
int Timer::get_min() { return sec/60; }
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
对象与指针
对象是值语义
对象可以包含指针成员,它指向的东西不计入对象大小
指针可以指向对象,也可以当作对象数组来操作
对象内部的指针
- 指针指向的目标并不属于对象“领地”
class MyA
{
int x;
char* p; // 指向的东西不在对象里
};
对象的复制会造成指针牵连现象
- 对象内部指针指向动态内存要小心(设计不好,就会泄漏)
==内存泄漏==过程示例
class MyA
{
public:
void set(char* name, int age){
x = age;
p = new char [strlen(name)+1];
strcpy(p,name);
}
private:
int x;
char* p;
};
使用过程:
MyA a;
a.set("zhangsan", 10);
cout << sizeof(MyA) << endl;
修正:
class MyA
{
public:
void set(char* name, int age){
x = age;
p = new char [strlen(name)+1];
strcpy(p,name);
}
void finish(){
delete [] p;
}
private:
int x;
char* p;
};
指向对象的指针
通过指针来调用对象的成员函数,或访问成员变量
指针的运算是基于sizeof(对象)的
与一般指针无异,可以当作数组来使用
对象引用
在本质上,还是指针操作,形式上,可以当作别名。
实时效果反馈
1. 关于对象内部的指针成员,说法正确的是:__
A 指针成员指向的数据也计算在sizeof(本对象)内
B 指针成员指向动态内存时,当对象释放时,也会自动释放
C 指针成员不能为NULL
D 指针成员在对象复制时,只是复制指针本身,不包含其指向的东西
2. 关于对象引用,说法正确的是?:__
A 对象引用可以理解为对象别名
B 对象引用比对象指针操作速度更快
C 通过对象引用无法修改对象的内容,只能读取
D 对象引用本质是指针,其类型的 sizeof 为 4 字节。
答案
1=>D 2=>A
指针赋值与对象拷贝
指针赋值或者对象拷贝都可能引发指针牵连
这是复杂性的开端,
也是bug的发源地。
- 指针赋值
class MyA{
public:
int x;
int y;
void show(){
printf("(%d,%d)\n", x, y);
}
};
其实,就是从前的 Point 定义(增加了show函数)
存在问题的使用方法:
MyA* p = new MyA{10,20};
MyA* q = p; // 形式上的拷贝,不管内容的含义
q->x++;
p->show(); // ?? 可能觉得奇怪
delete p;
delete q; // bug
更有甚者,可能会有另一个问题:
MyA* p = new MyA{10,20};
MyA* q = new MyA{30,40};
q = p; // 苦乐不均啊!!
delete p;
delete q;
- 对象赋值(对象拷贝)
MyA* p = new MyA{10,20};
MyA q = *p; // 这个是对象内存区的形式拷贝(memcpy)
p->x++; // 不会产生牵连
q.show();
delete p; // 与 q 无关
但是。。。。
如果对象中有指针项,问题就复杂了。
class LinkNode{
public:
int data;
LinkNode* next;
};
LinkNode a{1,NULL};
a.next = new LinkNode{2,NULL};
LinkNode b = a;
// 这次如何释放变成了难题。。。。
内存图景:
实时效果反馈
1. 当对对象指针进行赋值时,发生了什么?__
A 两个指针的值不同,但指向对象内容相同
B 两个指针值相同,指向同一个对象
C 两个指针值相同,指向不同对象。
D 两个指针值不同,指向不同对象。
2. 对象默认的拷贝行为,说法正确的是:__
A 不能把堆空间的对象拷贝到栈空间
B MyA b = a; 把 b 原来的内存信息抹掉了,换成了与 a 相同的信息
C 当对象赋值 b=a; 时,如果b对象中有指针指向动态内存,则会自动回收。
D 对象赋值比指针赋值速度快
答案
1=>B 2=>B
浅拷贝与深拷贝
如果不满意默认的拷贝行为,可以自己定义copy函数
对象对其动态内存的管理
例如,如何复制一个链表?
class LinkNode{
public:
int data;
LinkNode* next;
void add(LinkNode* it){
it->next = next;
next = it;
}
void clear(){
if(next==NULL) return;
next->clear();
delete next;
next = NULL;
}
void show(){
cout << data << " ";
if(next) next->show();
}
LinkNode copy(); // 深度拷贝
};
猜测一下结果:
LinkNode a = {-1,NULL};
for(int i=9; i>=1; i--) a.add(new LinkNode{i,NULL});
//LinkNode b = a;
a.show(); cout << endl;
a.clear();
a.show(); cout << endl;
这样呢?
LinkNode a = {-1,NULL};
for(int i=9; i>=1; i--) a.add(new LinkNode{i,NULL});
LinkNode b = a;
b.show(); cout << endl;
a.clear();
b.show(); cout << endl;
默认行为:
我们来实现深度拷贝
拷贝会返回临时对象,不是对象指针。
class LinkNode{
public:
int data;
LinkNode* next;
void add(LinkNode* it){
it->next = next;
next = it;
}
void clear(){
if(next) next->clear();
delete next;
next = NULL;
}
void show(){
cout << data << " ";
if(next) next->show();
}
LinkNode copy(); // 深度拷贝
};
LinkNode LinkNode::copy()
{
LinkNode t = {data, NULL};
LinkNode* p = next;
LinkNode* q = &t;
while(p){
q->next = new LinkNode{p->data, NULL};
p = p->next;
q = q->next;
}
return t;
}
实时效果反馈
1. 在定义copy成员函数时,如果返回局部变量对象的指针,会怎样?
A 等到调用方拿到指针的时候,指针指向的对象已经是垃圾区了。
B 可以安全操作,不要忘记了释放它指向的内存
C 可以安全操作,不用释放内存,系统会自动释放
D 产生编译错误
2. 在定义copy成员函数时,如果返回一个动态申请的对象的指针,会怎样?__
A 调用方无法释放头节点的内存
B 调用方如果采用对象赋值来接收拷贝结果,会使头节点内存泄漏
C 调用方只要不忘记调用clear, 就不会内存泄漏
D 会造成两个链表内存牵连而不独立。
答案
1=>A 2=>B
成员函数与this指针
疑惑
class Timer{
public:
void set(int x);
int get();
void tick();
private:
int sec;
};
问题:sec变量在哪里?
void Timer::set(int x)
{
sec = x; // sec从何而来?类里?
}
int Timer::get()
{
return sec; // 与上一个sec是不是同一个变量?
}
问题:到底是一个sec,还是多个?存在哪里?什么时候出生的,什么时候释放?
Timer a;
a.set(10);
Timer b;
b.set(20);
cout << a.get() << endl;
成员函数与普通函数的区别
成员函数是c++新创造的事物吗?
如果没有面向对象,c语言能实现这个功能吗?
我们可以!
多设计一个参数,就实现了一切!
struct T{
int sec;
};
void set(T* that, int x)
{
that->sec = x;
}
int get(T* that)
{
return that->sec;
}
调用处:
T a;
set(&a, 10);
cout << get(&a) << endl;
this的身份
this 是编译器隐藏传入的一个指针
是常量,不能更改
无法重新赋值或进行指针运算
它表达的含义:当前正在哪个对象上工作
就是说:this 象征者当前对象。
==输出当前对象的地址来看一看==
有什么大用?我不知道有妨碍吗?
很多用处,但现阶段…..
当名字冲突时,可以明确指定
void Timer::set(int sec)
{
//sec = sec;
this->sec = sec;
}
要求返回当前对象本身该怎么写呢?
Timer* Timer::tick()
{
if(sec>0) sec--;
return this;
}
还可以:
Timer& Timer::tick()
{
if(sec>0) sec--;
return *this;
}
实时效果反馈
1. 关于 this 的说法,正确的是:__
A this 是系统定义的静态变量,并且隐藏的。
B this 可以被重新赋值
C this 是系统隐含传入的一个形参变量
D this 不可以作为返回值
2. this 的哪个用途不能用其它办法代替? __
A 形参名与成员数据名相同
B 在成员函数中,调用当前对象的成员函数
C 输出当前对象的内存地址
D 返回当前对象的引用,以支持链式调用。
答案
1=>C 2=>D
构造子与析构子
对象出生时,内存是一片垃圾,这在有的场合无法忍受。
怎样==强制==用户必须初始化对象呢?
构造函数的目的
确保对象在任何场合被创建时,都是处于合理的状态(不是垃圾堆数据)
构造函数的语法:
与类同名
没有返回值(不是void)
可以有多个(重载 overload)
之所以称为:构造子,而不说构造函数,是因为它与一般函数不同,不能随意调用
构造函数,析构函数是系统自动调用的,我们无法控制什么时候调用或不调用
当我们不定义构造函数时,系统提供一个无参默认构造,什么都不干。
当定义任意构造函数,表示用户自己接手,系统默认提供的收回。
class LinkNode{
public:
LinkNode(int x){
data = x;
next = NULL;
}
int get(){ return data; }
private:
int data;
LinkNode* next;
};
这时,如果:
LinkNode a; // 会报错,因为没有找到构造函数
可以补充一个无参构造:
LinkNode(){
data = 0;
next = NULL;
cout << "..LinkNode().." << endl;
}
我们可以观察它什么时候被调用
LinkNode a;
a = LinkNode{};
对象创建的场合很多,经常是:防不胜防。
我们怎么知道对象有多少个?
析构函数追踪每个对象的消亡。
~LinkNode(){
cout << "..LinkNode...die..." << endl;
}
析构函数的目的
对象生前持有的==资源==在临终前,一定要归还,不管是何种原因死亡。
资源是广义,不一定是内存,还可能:文件句柄,画刷,socket等
其目的是:强制性 归还资源
比如,单链表,怎样保证它在销毁时不要忘记释放堆内存资源。
class LinkNode{
public:
LinkNode(int x){
data = x;
next = NULL;
}
~LinkNode(){
cout << "destroy... " << endl;
if(next) delete next;
next = NULL;
}
LinkNode& add(int x){
LinkNode* p = new LinkNode(x);
p->next = next;
next = p;
return *p;
}
void show(){
LinkNode* p = this;
while(p){
cout << p->data << " ";
p = p->next;
}
cout << endl;
}
private:
int data;
LinkNode* next;
};
使用方法:
LinkNode head(-1);
head.add(1).add(2).add(3);
head.show();
实时效果反馈
1. 关于构造子的用法,说法正确的是:__
A 用户必须在适当的时候调用构造子
B 用户定义有参的构造子,系统提供无参的构造子
C 构造子是系统自动调用的,用户无法调用
D 有参的构造子必须调用无参的构造子
2. 关于析构函数,说法正确的是:__
A 其作用是:把对象的状态恢复到默认值
B 在对象销毁前,提供必要的收尾工作
C 把对象中的所有指针置为NULL
D 有多少个构造子,就对应多少析构子
答案
1=>C 2=>B
构造函数重载
析构函数只有一种形式,构造函数却经常花样百出。
构造函数经常有多个,为了不同的场合。
一般,无参构造一定要提供。
构造函数的花样
使用参数的默认值,来简化重载
class Circle{
public:
Circle(){
cout << "init Circle()" << endl;
}
// Circle(int x, int y){
// cout << "init circle(int, int)" << endl;
// }
Circle(int x, int y, int r=1){
cout << "init circle(int, int, int)" << endl;
}
};
在一个构造函数中调用另一个构造函数
Circle(int x, int y, int r=1){
Circle();
cout << "init circle(int, int, int)" << endl;
}
抄袭已有对象创建新对象
Circle(Circle* x){
cout << "init circle(Circle*)" << endl;
}
初始化列表
当一个对象中包含另一个对象时…..
包含指针不算,是包含完整的对象
观察初始化的顺序等行为:
// 平面直角坐标位置
class Point{
public:
Point(){
cout << "init point()" << endl;
x = 0;
y = 0;
}
Point(int x, int y){
cout << "init point(x, y)" << endl;
this->x = x;
this->y = y;
}
void set(int x, int y){
this->x = x;
this->y = y;
}
int getx() { return x; }
int gety() { return y; }
private:
int x;
int y;
};
// 平面上的圆形(圆心 + 半径)
class Circle{
public:
Circle(){
cout << "init circle()" << endl;
pt.set(0,0);
r = 1;
}
Circle(int r){
cout << "init circle(int)" << endl;
Circle();
this->r = r;
}
void show(){
printf("circle: (%d,%d)%d\n", pt.getx(),pt.gety(),r);
}
private:
Point pt; // 圆心
int r; // 半径
};
比较如下创建对象:
//Circle a; //此时,Point对象初始化几次?
Circle a(10); // Point对象初始化时机和次数?
//a.show();
如何在外层对象初始化之前,对其包含对象初始化??
比如,强制 圆心在 (1,1)
Circle():pt(1,1),r(10){
cout << "init circle()" << endl;
//pt.set(0,0);
//r = 1;
}
Circle(int r):Circle(){ //基于另一构造函数,完成本构造
cout << "init circle(int)" << endl;
//Circle(); //这样不好,会引发内部对象多次初始化
this->r = r;
}
非对象类型放在初始化列表里,没意义,但可以这么做。
实时效果反馈
1. 关于构造函数的说法,正确的是:__
A 构造函数的返回值类型为: void,即,无返回值。
B 构造函数可以重载,只要参数个数不等即可。
C 构造中,可以调用另外一个版本的构造函数
D 构造函数中,可以调用析构函数
2. 什么情况下,需要使用初始化列表:__
A 可用可不用,与在构造函数中写没有太大区别
B 当一个对象中含有其它对象时,需要在本对象初始化前为嵌入的对象初始化
C 建议所有成员数据都用初始化列表,这样效率高
D 当包含了另一个对象的指针或引用的时候
答案
1=>C 2=>B
对象的生存期
对象可以存在于栈,静态空间,或者堆内存中。
用于测试的类型 T,只有一个析构函数,必须是public
class T{
public:
~T(){
cout << "destroy .. T .. " << endl;
}
};
其对象大小为1字节。
栈中的对象
局部变量,在栈中分配,起始于左大括号,终于右大括号。
对象被自动创建,自动释放,自动调用构造,自动调用析构。
立即数
在表达式等语句中临时出现的对象。生存期只限于本语句。
cout << "111" << endl;
T{};
cout << "222" << endl;
for 全局
for(T a;0;){
}
for 函数体
for(int i=0; i<5; i++){
T a;
}
形式参数
void f(T x){ }
//。。。。调用:
T a;
f(a);
如果,传入的是 T&,则本质上是传 T*, 这不会引发创建新对象
返回值
T f(){
T a;
return a;
}
// 。。。。 调用:
T x;
x = f();
这在不同的环境表现不同。
有时,2个对象,有时 3 个对象。
看看极端情况:
T f(){}
// 调用。。。。
f(); f();
静态对象
void f(){
static T a;
}
// 调用。。。。。
f(); f();
对象动态申请与释放
new 类型 [ ];
返回对象指针delete [ ] 指针;
释放对象内存当使用对象数组的时候,必须用 delete [] p; 的形式
T* p = new T[5];
delete [] p;
如果忘记了 [ ],则落入著名的大坑,造成内存泄漏
实时效果反馈
1. 当形参是对象引用类型时,说法正确的是:__
A 调用函数会创建新的对象
B 调用函数不会创建新对象,这点上,与对象指针类型的形参表现相同。
C 调用时,会创建新对象,但函数结束不会自动调用析构函数
D 不确定,与编译器的设置有关
2. 当动态分配对象数组时,对应的 delete 语句忘记了方括号,会怎样?__
A 只释放了一个对象的内存,造成内存泄漏。
B 内存可以释放,但只调用了一个析构函数。
C 内存可以释放,但不会调用任何析构函数
D 会引发语法错误
答案
1=>B 2=>B
对象的传递
【示例对象】 计数器对象
class Cnt{
public:
Cnt(){ x = 0; }
~Cnt() { cout << "destroy .. counter ... " << x << endl; }
void inc() { x++; }
void dec() { x--; }
int get() { return x; }
private:
int x;
};
// 使用方法:。。。
Cnt a;
a.inc();
a.inc();
cout << a.get() << endl;
传递指针
最常见的情况是,主调方持有对象,把指针传入函数,希望操纵它
一般被调方没有管理其内存的责任。
void f(Cnt* p){
p->inc();
p->inc();
}
通过这种方式,也可以间接返回多个值。比如,返回点的坐标:
bool f(Point* p1, Point* p2){
if(p1->x < 0 || p1->y <0) return false;
*p2 = *p1;
p2->x++; p2->y++;
return true;
}
逻辑上,p1 是输入参数,p2是输出参数
传拷贝
传拷贝的应用场合是,不希望函数的操作影响主调方。
void f(Cnt x)
{
for(;x.get(); x.dec()){
cout << "f..work..." << endl;
}
}
int main()
{
Cnt a;
a.inc();
a.inc();
f(a);
cout << a.get() << endl;
return 0;
}
传引用
传引用,本质就是传指针,但可以使用对象而不是指针的而语法
另外的好处是:
引用不能运算,指针可以运算,
void f(T& x){
x++; // ????
x[2] = ... //???
}
总之,引用有了更多的限制,更安全。
顺便说:
java中的指针,不叫指针,叫引用,道理即此。
返回指针
传入的指针,再传回去,耗费资源很少,带来方便。。。。
比如,链式操作。
动态申请的对象,返回地址,需要调用方去释放资源。
主调方一般通过对称的函数来完成释放。
Cnt* make_cnt()
{
// 可能根据配置文件,完成对象的动态创建
return new Cnt();
}
bool free_cnt(Cnt* p)
{
delete p;
}
典型错误:
返回局地对象的指针,使用方拿到时,此对象已作废。
T* f(){
T a;
return &a; // 当对方收到这个指针时,a 已经死亡了。
}
返回引用
最常见的用法是:踢皮球
传入的参数,再传回去,一般就是为了链式调用。
Cnt& inc() { x++; return *this; }
则,调用时:
a.inc().inc().inc();
实时效果反馈
1. 下列说法正确的是:__
A 形参为指针类型的目的是:修改主调方实参的指针值
B 形参为指针,在调用时不会产生创建新对象的动作
C 形参为对象引用,比形参为对象指针耗费更多的内存
D 形参为指针时,如果在函数内被置为NULL,会清空主调方的对象。
2. 如果,在一个函数中,返回了本地对象的指针,将会导致__
A 该对象无法被释放,导致内存泄漏
B 本地对象可以被释放,但不会调析构函数
C 主调方拿到指针时,本地对象已经销毁了,导致悬挂指针
D 主调方拿到的指针为NULL
答案
1=>B 2=>C
静态成员函数
静态成员函数实际上接近于普通函数,只是拥有对对象的某些访问权限。
语法
函数用 static 修饰
调用时,不需要对象,直接 类名::静态成员函数
此时,类名的作用类似于:命名空间
逻辑上,静态成员函数,不封装到对象,但封装到类(外部可见性自己控制)
静态成员函数的常见使用场景
运算是对称的,如果使用
对象.成员函数
的方式,则体现不出对称性仔细体会下面两个
add
函数语义上的微妙差异class Cnt{
public:
Cnt(){
x = 0;
}
Cnt& inc(){ x++; return *this; }
Cnt& dec(){ x--; return *this; }
void show() { cout << x << endl; }
void add(Cnt& t){ // t 的值并入 我自己
x += t.x;
}
static Cnt add(Cnt& a, Cnt& b){ // a,b都不变,返回新的
Cnt t;
t.x = a.x + b.x;
return t;
}
private:
int x;
};
第一个运算数不是对象,无法用
对象.成员函数
的方式static Cnt add(int a, Cnt& b){
Cnt a1;
a1.x = a;
return add(a1, b);
}
作为与本类型相关工具函数,并不需要对象
static void help(){
cout << "_____ Cnt help ____" << endl;
cout << " Cnt a_name; " << endl;
cout << " a_name.inc(); // add one " << endl;
cout << " ....... " << endl;
}
其任务就是创建对象,现在流行称为:工厂方法
当然在对象出生之前就可以调用。
一般是根据配置文件,动态创建出对象,返回对象的指针。
static Cnt* create(const char* txt){
Cnt* p = new Cnt();
sscanf(txt,"init=%d",&p->x);
return p;
}
// 调用方:
Cnt* p = Cnt::create("init=50");
p->show();
delete p;
静态成员函数的特征
没有自动传入的this指针
并不需要对象的存在,就可以被调用
实时效果反馈
1. 关于静态成员函数,说法==错误==的是:__
A 静态成员函数无法通过对象来调用。
B 静态成员函数的形参,并没有传入隐含的this指针
C 静态成员函数,可以通过类名限定来调用
D 静态成员函数,可以用来动态创建对象实例
2. 以下关于静态函数说法,==错误==的是__
A 静态成员函数比普通成员函数少了this指针
B 静态成员函数,可以在本类型的所有对象出生前调用
C 静态成员函数,可以作为本类型的工具函数,并不依存于对象
D 静态成员函数,可以用来释放对象申请的动态内存
答案
1=>A 2=>D
静态成员变量
静态成员变量是类级别的,不随着对象的出生而出生,所有对象出生前就存在了。
静态成员变量语法
定义时,加 static 修饰符
例如: static int x;
必须在外部进行初始化
例如: int T::x = 100;
这里,充分体现了类是蓝图,它不会耗费内存。
静态成员变量特性
对象未创建,就已经存在,可以用静态方法操纵它
与全局变量,静态变量类似,存在静态空间
只有一份,不跟随对象创建,不属于sizeof(对象) 大小
它可以被本类型所有对象共享。
class T{
public:
int a; //跟随对象而生灭
static int A; //只有一份,需要在外部创建并初始化
};
当,如下创建,内存图景:
T a;
T* p = new T();
delete p; // 不要用free,那样不会调用析构
静态成员变量的典型用途
用于本类型对象的管理
例如:检测内存泄漏。用一个 int 记录本类型对象现存的个数。
class T{
public:
T(){ A++; }
~T(){ A--; }
int x;
static int A;
};
设计模式中,著名的:单例模式
有的时候,我们希望只有唯一的对象(比如,连接sqlite数据库的连接对象)
这可以通过把构造函数私有化来实现。
提供 static 函数来获得这个唯一对象。
class T{
public:
static T* get_instance(){
if(pObj==NULL) pObj = new T();
return pObj;
}
static void free(){
if(pObj){
delete pObj;
pObj = NULL;
}
}
private:
T(){}
~T(){}
static T* pObj;
};
T* T::pObj = NULL; // 不要忘记真正创建变量
int main()
{
T* p1 = T::get_instance();
T* p2 = T::get_instance();
cout << (p1 == p2) << endl;
return 0;
}
实时效果反馈
1. 关于静态成员变量,说法正确的是:__
A 静态成员变量可以在类内直接赋给初始值
B 静态成员变量必须在类外再次声明,以分配存储空间。
C 静态成员变量必须在静态成员函数中才能访问
D 静态成员变量在所有该类对象都析构后,自动释放内存。
2. 单例模式的作用是:__
A 确保所有使用者,使用的均是同一个对象。
B 保证对象在程序启动时,就已经创建好。
C 可以更快速地创建对象
D 可以让多个对象,通过同一个指针被外界使用。
答案
1=>B 2=>A
对象的状态
对象是持有状态的实体
纯函数是:输入到输出的映射关系。普通函数=纯函数 + 副作用。
同一个类,创建的多个对象可以处于不同的状态。
成员函数的执行,与其关联的对象有关,即,该函数执行的上下文(contex)
饮料机a.购买(可乐,1)
饮料机b.购买(可乐, 1)
对象的状态迁移
外界对对象发出指令(向对象发送消息)
对象响应消息,改变自身的状态(这是与普通函数式思维的不同)
【实例】小机器人对象
我们先设计一下,小机器人有哪里能力。
然后写出它的使用方式【测试驱动法TDD】
Robot a;
a.go(5);
a.right();
a.go(10);
a.left().left().go(20); //需要支持链式
a.show();
a.reset();
a.show();
最后,实现小机器人类:
enum DIR{
NORTH, EAST, SOUTH, WEST
};
struct Point{
int x;
int y;
Point(){
x = 0;
y = 0;
}
void show(){
printf("(%d,%d)", x, y);
}
};
class Robot{
public:
Robot(){
dir = NORTH;
}
Robot& go(int step){
switch (dir) {
case NORTH:
pt.y += step;
break;
case EAST:
pt.x += step;
break;
case SOUTH:
pt.y -= step;
break;
case WEST:
pt.x -= step;
break;
}
return *this;
}
Robot& left(){ dir = (DIR)((dir-1+4)%4); return *this; }
Robot& right(){ dir = (DIR)((dir+1)%4); return *this; }
void show() {
pt.show(); cout << " ";
switch (dir) {
case NORTH:
cout << "north";
break;
case EAST:
cout << "east";
break;
case SOUTH:
cout << "south";
break;
case WEST:
cout << "west";
}
cout << endl;
}
void reset(){
pt.x = 0; pt.y = 0;
dir = NORTH;
}
private:
Point pt; //当前坐标
DIR dir; // 朝向
};
封装的含义
封装是隔离
隔离了功能和实现,隔离了外部特征和内部实现机制
封装是关联
关联了成员函数和对象状态,通过函数改变状态,函数服务于状态,不能独立存在
封装是隐藏
隐藏了数据,隐藏了结构,隐藏了实现细节
拓展:
只有一个小机器人对象,是寂寞的世界
如果,有多个小机器人
go 需要考虑碰撞的问题
可以相互协作完成任务
。。。。
让小机器人走迷宫,到岔路口,像孙悟空一样,复制出多个小机器人,走不同的路
两个撞了,就自动销毁一个。。。。。
实时效果反馈
1. 关于对象的封装性,说法正确的是:__
A 封装就是把对象加锁,避免同时访问
B 封装就是把对象保护起来,避免与外界交互
C 封装一般是把成员函数隐藏,把数据暴露出来
D 封装就是把结构和实现隐藏,把功能暴露出来
2. TDD在面向对象中的大体做法是:__
A 自顶向下的设计
B 螺旋上升的开发模型
C 先写对象如何使用,然后在需求的推动下,完善类的功能
D 先写出对象的数据,围绕着数据写它的功能
答案
1=>D 2=>C
对象的状态(2)
接上节,小机器人类实现细节的补充:
TDD开发风格,先写如何用,再写如何实现
让==需求推动==着我们去实现功能
降低程序员的心智负担,只关注当前的问题
TDD提倡==渐进式==的风格,不要一次写很多,改一点,测试一点
通过不断反馈,不断完善,来接近最终目标
当条件允许时,优先采用可读性好的写法,易于维护
程序的生命力在于它的可维护性,而不是正确性
注意枚举类型与整型的关系
自动转换关系
重用性很重要,提供何种功能,有利于重用?有利于组合?
功能单一性——高内聚
联系简化 —— 低耦合
在面向对象的设计中,一般优先使用==组合模式==,其次才是继承模式
组合,就是在对象数据中包含其它的对象(或指针,也有把这个称为==聚合==的)
实时效果反馈
1. 关于c++枚举类型与整型关系,说法正确的是:__
A 整型和枚举类型间可以自动相互转化
B 枚举类型可自动转整型,整型不能自动转枚举
C 整型可自动转枚举,枚举不能自动转整型
D 两者类型不同,不可自动转化
2. 为了提高软件单元的复用性,设计时,我们应该遵循__原则
A 高内聚,高耦合
B 高内聚,低耦合
C 低内聚,高耦合
D 低内聚,低耦合
答案
1=>B 2=>B
对象内存结构
当没有继承,没有虚函数的时候,c++的对象内存模型相当简单
空对象,占用 1 字节
普通成员数据,跟随对象存储,属于sizeof(对象),可能涉及字对齐
静态成员数据,全局存储,静态空间,程序启动后确定下来
普通成员函数,静态成员函数,存储在代码区,不跟随对象
逻辑上: 对象 = 数据 + 方法
存储上: 对象 = 数据
成员函数是障眼法,被翻译为普通函数,多一个隐含的参数 this 指针
静态成员函数,没有this,因而等价于普通函数,只是编译器控制了访问权限
不管对象创建多少,函数都只有一份拷贝
不管是:
对象.f()
指针->f()
还是引用.f()
,本质上都一样,调用f,传入一个指针,名字为 this,装着对象内存首地址
当对象的数据中含有其它对象时,累计其size;当含有任何类型指针,增加4字节
当对象的数据中,含有数组,则累计数组size,若含有数组地址,则只增4字节
当有指针指向动态内存,只增4字节
但,注意,一般在析构中要释放动态申请的内存。
示例
【Stu对象】包含:学号,姓名,分数。要求姓名采用动态存储法
class Stu{
public:
Stu(int id, char* name){
this->id = id;
this->name = new char [strlen(name)+1];
strcpy(this->name, name);
score = 0;
}
~Stu(){
delete [] name;
}
void inc(int x=1){ score += x; }
void dec(int x) { score -= x; }
void show(){
cout << id << ": " << name << "," << score << endl;
}
private:
int id;
char* name;
int score;
};
管理对象数组
对象数组是,包含对象本身的数组。其元素是对象。
如果需要创建对象数组,对象必须支持无参构造的方式。
如果不能接受对象的随机值,希望表达“无意义”这个概念。可以选用特殊值。
如果希望==批量初始化==对象,一般要需要一个 init 函数
通常,批量初始化的数据有某种连续特征,可以通过运算动态获得。
如果在栈中开数组,
会自动调用析构函数。
在堆中,则需要 delete [ ] p 来触发
管理对象指针数组
对象指针数组包含的是对象的指针,数组的元素是指针
也就是说,释放数组,并不会自动释放对象。
当管理对象时,我们需要考虑==两个==问题:
- 对象的内存释放了吗?
- 对象的析构函数调用了吗?
这是两个独立的问题,要小心!!!
实时效果反馈
1. 关于对象的存储,说法正确的是:__
A 对象的数据和操纵它的成员函数存在一起。
B 对象的内存包含了它的非静态数据和this指针。
C 当无继承时,对象的内存包含了它的所有非静态数据。
D 对象的内存总是动态分配的,其大小等于所有public数据大小之和
2. 关于对象指针数组,说法正确的是:__
A 在栈中分配的数组释放时,会自动调用每个指针的析构函数
B 在栈中分配数组,每个元素必须是同一类型对象的指针
C 本质就是指针数组,指针指向的内容由用户自行管理
D 数组可以自动分配,但,删除时需要调用 delete [ ] p;
答案
1=>C 2=>C
对象内存结构(2)
接上节,对象及其动态内存的使用,有许多==著名大坑==,
作为成熟的程序员,每个都应该踩过。
- 养成好习惯,delete 前判断NULL,删除后置为NULL
- 指向动态内存的指针被重新赋值,赋值前,问自己:释放了不?
- 只要不是构造函数,都可能被调用多次,一定要考虑好多次调用的后果。
- free 不是 delete,它会释放内存,但不调用析构
- delete 不是 delete [ ], 它会释放内存,会调用析构,但只调用 1 个。
- 养成好习惯,自己 new … , 一定检查是否有对一个的 delete
- 避免动态内存的最好方法之一:尽量不用它(用数组代替指针)
class Stu{
public:
Stu(){
id=-1;
name[0] = '\0';
score=-999;
}
Stu(int id, char* name):Stu(){
init(id, name, 0);
}
~Stu(){
cout << "destroy...stu.." << id << endl;
}
void init(int id, char* name, int score){
this->id = id;
strncpy(this->name, name, 25);
this->score = score;
}
void inc(int x=1){ score += x; }
void dec(int x) { score -= x; }
void show(){
cout << id << ": " << name << "," << score << endl;
}
private:
int id;
char name[30];
int score;
};
实时效果反馈
1. 对成员函数的指针数据重新赋值, 可能引起什么不良后果:__
A 悬挂指针
B 内存泄漏
C 忘记调析构函数
D 触发垃圾回收
2. 成员指针 delete 后,不置为NULL,有什么隐患?__
A 悬挂指针
B 内存泄漏
C 栈溢出
D 数组访问越界
答案
1=>B 2=>A
拷贝构造
拷贝构造就是对象克隆
一个对象,可能有各种各样的出生方式。
指定合适的参数出生(最容易识别)
无参默认出生
有时,是隐式的:
比如,对象数组
比如,在一个对象中包含了另一个对象
拷贝另一个对象而出生
有时,很隐蔽。
比如:对象实参传递给形参;函数返回一个对象
由赋值语句而出生
把一个对象赋值给另一个对象
我们来监测对象的出生和销毁
class A{
public:
A(){ cout << "default create" << endl; }
A(A& x){ cout << "copy create" << endl; }
~A(){ cout << "destroy " << endl; }
};
注意:
拷贝构造的形参,一定是引用类型,否则本身就会引发拷贝构造。
在其它场景,同样使用引用可以避免拷贝的发生
A a;
A b = a; // 拷贝构造
A c(a); // 拷贝构造
b = c; // 这是对象赋值,并没有新的对象出生,因而无所谓:“构造”
函数中传递的情况:
A f(A x){ return x; }
// ..... 调用处:
A a;
f(a);
如果不定义拷贝构造
系统执行默认的拷贝行为,可能不是我们期望的。
可以理解为:内存级别的“照相”复制
即是: memcpy(&b, &a, sizeof(a));
这对简单对象,完全正确,比如: Point, Cnt 等
如果对象内有指针就要当心了,会造成:指针牵连
浅拷贝与深拷贝
系统默认的拷贝构造行为是:浅拷贝。
如果需要深拷贝,则自己定制。
我们前面讲过:浅拷贝,深拷贝的话题。其内容与对象拷贝完全类似。
甚至,如果我们已经写好了 copy 函数,可以在拷贝构造中,直接调用它。
下面给出链表对象的拷贝构造例子:
队列对象
struct Node{
int data;
Node* next;
Node(int x){
data = x;
next = NULL;
}
};
class Que{
public:
Que() { head=NULL; tail=NULL; }
~Que() { int x; while(pop(x)); }
Que& push(int x){
Node* t = new Node(x);
if(tail){
tail->next = t;
tail = t;
}else{
tail = t;
head = t;
}
return *this;
}
bool pop(int& x){
if(head==NULL) return false;
Node* t = head;
if(head==tail){
head = NULL;
tail = NULL;
}else{
head = t->next;
}
x = t->data;
delete t;
return true;
}
private:
Node* head;
Node* tail;
};
基本用法:
Que a;
a.push(10).push(20).push(30);
int x;
while(a.pop(x)) cout << x << endl;
但,有个致命隐患,当拷贝队列对象的时候。。。。。
定义拷贝构造来复制整个队列:
实现起来也不难,关键是充分利用已经有的基础设施
Que(Que& t):Que(){
Node* p = t.head;
while(p){
push(p->data);
p=p->next;
}
}
实时效果反馈
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
赋值函数
前边的课程中,我们知道:
运算符的本质就是函数,参数就是运算数
= 是运算符,b = a 的默认动作是:把 a 的值拷贝给 b,同时返回 b 的值
当,a b 都是对象时,复杂了。。。。
默认的赋值行为
b = a; 把 a 对象的内存“照相”拷贝到 b,返回b对象的引用。
当简单对象时,这种行为是正确的。
当对象复杂时,比如,含有指针,产生牵连(与拷贝构造类似)
除此之外,还有更严重的错误:
上一节的 Que 类
// 链表的节点
struct Node{
int data;
Node* next;
Node(int x){
data = x;
next = NULL;
}
};
//先进先出的队列
class Que{
public:
Que() { head=NULL; tail=NULL; }
Que(Que& t):Que(){ //保证在拷贝构造前,head,tail为NULL
Node* p = t.head;
while(p){
push(p->data);
p=p->next;
}
}
~Que() { int x; while(pop(x)); }
Que& push(int x){ //返回Que& 为了链式操作
Node* t = new Node(x);
if(tail){
tail->next = t;
tail = t;
}else{
tail = t;
head = t;
}
return *this;
}
bool pop(int& x){ //不设计为直接返回 int,因为有队列空的情况
if(head==NULL) return false;
Node* t = head;
if(head==tail){
head = NULL;
tail = NULL;
}else{
head = t->next;
}
x = t->data;
delete t;
return true;
}
private:
Node* head; //指向对头,从这里弹出数据
Node* tail; //指向队尾,在其后压入数据
};
赋值函数语法
~Que() { clear(); }
void clear(){ int x; while(pop(x)); }
Que& operator=(Que& t){
clear();
Node* p = t.head;
while(p){
push(p->data);
p=p->next;
}
return *this;
}
实时效果反馈
1. operator= 运算符重载,为什么要返回引用类型__
A 为了支持连续赋值,形如: c = b = a;
B 语法规定,否则编译不过
C 为了和形参一致
D 为了兼容 c 语言
2. 与默认的拷贝构造行为相比,默认的对象赋值行为,可能增加的危害是:__
A 对象牵连
B 悬挂指针
C 内存泄漏
D 栈溢出
答案
1=>A 2=>C
友元函数
一个规范的类,
应该至少满足:封装性,即是,它把内部的数据和实现机制隐藏起来。
但,有的时候不方便,
比如,这个类可能需要向调试、测试、诊断等工具放开权限。
类比现实世界,
每个人都有隐私,但,我们不应该对医生或牧师隐瞒。
c++,为了高效率解决这个冲突,引入了友元函数
什么是友元函数?
一句话来概括:友元函数就是一个对某个类有特权的普通函数。
谁来授权?
类对该函数授权,加 friend 修饰符。
要点:
- 友元函数只是外部的普通函数,并非本类成员函数,因而:
- 它没有隐藏的 this 指针。
- 如果需要它操纵对象(读取或改写),必须显式地传入对象。
- 它不需要this,当然,可以使用类的静态成员函数或静态成员数据。
真实的例子(月结打卡类Punch)
class Punch{
public:
Punch(){ data = 0; }
void set(int day){
if(day<1 || day>31) return;
data |= 1 << (day-1);
}
bool get(int day){
if(day<1 || day>31) return false;
return data & (1 << (day-1));
}
private:
unsigned int data; // 按二进制位记录每天打卡情况
};
使用方法:
Punch a;
a.set(5);
a.set(8);
cout << a.get(2) << endl;
cout << a.get(5) << endl;
某诊断函数需要输出 data 的真实值
void debug_punch(Punch& x){
cout << "puch.data:" << hex << x.data << endl;
}
在Punch类中,对这个普通函数放开private权限
friend void debug_punch(Punch& x);
测试一下:
Punch a;
a.set(5).set(10).set(12);
cout << a.get(2) << endl;
cout << a.get(5) << endl;
debug_punch(a);
实时效果反馈
1. 关于友元函数, 说法正确的是:__
A 友元函数是特殊的成员函数,一般用来输出诊断信息
B 友元函数就是类的静态成员函数,没有 this 指针。
C 友元函数是普通函数,获得了类的特殊访问授权。
D 友元函数是c++的特殊函数,专门为了调试而设计的。
2. 友元函数与this指针的关系:__
A 有隐含的 this 指针,但只能读,不能写。
B 没有隐含 this 指针,可以访问类的静态数据
C 有隐含 this 指针,但无法访问类的静态数据
D 没有隐含的 this 指针,也无法访问静态数据
答案
1=>C 2=>B
友元类
为了体现封装性,把对象的私有数据隐藏是很棒的实践!
但,有些场合,效率是很重要的,
我们希望,某些我们信任的类,可以操作我的 private 成员。
授权声明
与 友元函数类似,可以向某个类授权,该类的方法可以突破本类的private限制。
注意,这个授权是单向的。
B 是 A 的友元类,并不一定:A 是 B 的友元类。
如果需要,则双向都要授权才行。
真实案例
重新审视,在前边做过的Que类,
Que a; a.push(10).push(20).push(30);
Que b; b.push(33).push(99);
b = a;
int x; b.pop(x); b.pop(x);
while(a.pop(x)) cout << x << endl;
属于傻瓜式用法,用户无法操纵Node对象,内存完全由Que管理。
在复杂场合有效率问题。
比如,从一个队列 b 中拿来一个元素,放入另一个队列 a 中。
可不可以,让用户管理 Node 呢?
Que a;
a.push(new Node(10));
a.push(new Node(20));
a.push(new Node(30));
Que b;
b.push(new Node(100));
b.push(new Node(200));
delete b.pop(); //从队列出来的是 Node* 类型,要么删掉
a.push(b.pop()); // 要么,挂接到别处
a.show(); cout << endl;
Que的设计:
class Que{
public:
Que() { head=NULL; tail=NULL; }
Que(Que& t):Que(){ copy(t); }
~Que() { clear(); }
void clear(){ Node* p; while(p=pop()){ delete p; } }
void copy(Que& t){
for(Node* p=head; p; p=p->next) push(p->copy());
}
Que& operator=(Que& t){
clear();
copy(t);
return *this;
}
Que& push(Node* p){
if(tail){
tail->next = p;
tail = p;
}else{
tail = p;
head = p;
}
return *this;
}
Node* pop(){
if(head==NULL) return NULL;
Node* t = head;
head = t->next; t->next = NULL;
if(head==NULL) tail = NULL;
return t;
}
void show(){
for(Node* p=head; p; p=p->next){
p->show(); cout << " ";
}
}
private:
Node* head;
Node* tail;
};
限制 Node 的 public 功能
class Node{
public:
Node(int x){ data = x; next = NULL; }
Node* copy(){ return new Node(data); }
void show(){ cout << data; }
private:
int data;
Node* next;
friend class Que;
};
实时效果反馈
1. 关于友元类, 说法正确的是:__
A 如果 A 是 B 的友元类,则,B 也是 A 的友元类
B A 和 B 不能相互是对方的友元类
C 我的友元类不能访问我的 static 成员
D 友元类授权等价于,对该类的所有函数,进行友元函数授权
答案
1=>D
内部类
内部类,也有称为:嵌套类
就是把一个类定义在另一个类的内部。
class A{
....
class B{
};
....
};
需要注意:
c++的内部类与 java 不同。
没有复杂的外部对象 this 引用,依存关系等等。
c++的内部类与其外部类间联系更弱,除了名字空间的限定外,几乎就是两个独立的类。
如果说便利条件,有一点点:
内部类可以访问外部类的 private 成员,不用开通友元。
何时需要内部类?
考虑这样的情景,
我们的多个数据结构:
比如说,我们定义了:栈,队列,链表,双向链表等都有 Node 概念。
一种方法是:XXX_Node,通过类名字来区分。
还有一种,把Node定义挪到主类的内部,这样,访问时:
Que::Node, Link::Node, Stack::Node 等等来区分
这里,外部类起到了 名字空间 的作用。
class Que{
public:
class Node{
public:
Node(int x){ data = x; next = NULL; }
Node* copy(){ return new Node(data); }
void show(){ cout << data; }
private:
int data;
Node* next;
friend class Que;
};
public:
Que() { head=NULL; tail=NULL; }
Que(Que& t):Que(){ copy(t); }
~Que() { clear(); }
void clear(){ Node* p; while(p=pop()){ delete p; } }
void copy(Que& t){
for(Node* p=head; p; p=p->next) push(p->copy());
}
Que& operator=(Que& t){
clear();
copy(t);
return *this;
}
Que& push(Node* p){
if(tail){
tail->next = p;
tail = p;
}else{
tail = p;
head = p;
}
return *this;
}
Node* pop(){
if(head==NULL) return NULL;
Node* t = head;
head = t->next; t->next = NULL;
if(head==NULL) tail = NULL;
return t;
}
void show(){
for(Node* p=head; p; p=p->next){
p->show(); cout << " ";
}
}
private:
Node* head;
Node* tail;
};
friend 修饰符还是需要的。
如果内部类访问外部类的 private 成员,则不需要授权。
需要再次强调:
不管是内部类访问外部类,还是外部类访问内部类,
都需要先获得被操作对象(或指针,或引用)
没有默认的 this 指针等内部联系。
实时效果反馈
1. 关于内部类, 说法正确的是:__
A 要创建内部类对象,必须先创建其外部类对象
B 要创建外部类对象,必须先创建其所有内部类对象
C 创建外部类对象的同时,隐含创建了内部类对象
D 外部类对象,内部类对象都可以独立创建或不创建
2. 下列关于访问权限,说法正确的是:__
A public 的内部类,可以在外部类以外创建对象,直接用内部类名。
B public 的内部类,可以在外部类以外创建对象,用
外部类::内部类
限定C private 的内部类,在外部类以外,也可以创建内部类对象
D private 的内部类,在外部类中,也无法创建它的对象。
答案
1=>D 2=>B
运算符重载
函数可以重载,即:函数名相同,但参数类型不同或个数不同。
运算符是变相的函数,因而可以重载。
有些场合,运算符比函数的表达更直观。尤其,引入了对象。
比如两个矩阵 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 | = += -= *= /= &= \ | = ^= <<= >>= |
应用实例
还以前边做过的计数器
类为例子:
如果,我们希望这样使用它:
Cnt a; a.inc(); a.inc();
Cnt b; b.inc();
Cnt c = 100 + a + b + 10;
cout << c.get() << endl;
cout << c << endl;
这里,需要重载哪些运算符呢? 哪些是必须重载为友元函数的呢?
class Cnt{
public:
Cnt(){ x=0; }
Cnt& inc() { x++; return *this; }
Cnt& dec() { x--; return *this; }
int get() { return x; }
Cnt operator+(Cnt& t){ return Cnt(x+t.x); }
Cnt operator+(int t){return Cnt(x+t); }
private:
Cnt(int a){ x = a; }
int x;
friend Cnt operator+(int, Cnt&);
friend ostream& operator<<(ostream& os, Cnt&);
};
Cnt operator+(int a, Cnt& b) { return Cnt(a + b.x); }
ostream& operator<<(ostream& os, Cnt& t)
{ 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
重载运算符基本规则
为了防止用户把系统的标准运算改乱,比如:
1 + 2
,规定:运算数至少有一个用户自己定义的类型。
不能改变运算符原来的目数,比如:
%myObj
,不可以% 是双目运算符。(但,有些运算符可单可双)
不能改变原来的优先级和结合律
a+b*c
无论怎么重载,还是先算 b * c,结果再加法不能自创一个运算符,很多人有重载 的冲动。希望 myObj 2 表示平方,不可以。
a => b
也不行,不存在这个运算符有些高级语言有:
a <=> b
, 返回值是 -1,0,1, 确实很优美,但c++不可。不能改变原来的操作数位置(主要指单目) a! a~ 都是不可以的
它们原来的用法都是前置运算符。
并非所有 c++运算符都可以重载
比如:
.
(成员运算)::
(域运算符)sizeof
(取长度)?:
(条件运算)不能给参数设置默认值。
那样就很难区分目数,容易引起歧义。
特殊的运算符,如:( )、[ ]、->、=,重载时必须将声明为成员函数,而不能声明为友元函数。
这实际上是要求,左边需要用户定义类型,而不能是系统类型。
这个需求很明显:
x[5] 的行为可以重新解释;
但,5[x] 就太怪异了,因而要禁止!
c++的运算符重载,本着实用主义的宗旨:
没有它也可以完成任务,
有了它,绝不能影响效率或者引发混乱。所以,其几乎没有专门设计,
功能较弱,也不完善。
踩坑
如果重载了 && || 运算符,会发现,它的短路求值功能不见了。
为了便于输出,cout << 自己类型,重载注意,不会改变优先级。
cout << a & b << endl;
必然是等价于: (cout << a) & (b << endl);
如果拿不定注意,就多加括号吧!
真正理解 ++, — 的机会
我们在前边课程中讲过:
a++,++a 的区别:
b = a++, 先执行++,后执行 = (不是: 先赋值,再++)
a++ 运算的副作用是:改变了 a 的值,运算的结果:返回了a 改变前的值。
我们把这个东西套用到 Cnt 类,来体验一下:
class Cnt{
public:
Cnt(){ x=0; }
Cnt& inc() { x++; return *this; }
Cnt& dec() { x--; return *this; }
int get() { return x; }
Cnt& operator++(){ x++; return *this; }
Cnt operator++(int){ Cnt t = *this; x++; return t; }
// 注意这个返回值类型,不能用引用形式。
private:
Cnt(int a){ x = a; }
int x;
};
实时效果反馈
1. 关于运算符重载, 说法正确的是:__
A 不能改变原来的优先级和结合律。
B 可以改变原来的目数
C 可以定义系统原来没有的运算符
D 双目运算符都可以重载为友元函数
2. 不能重载的运算符是:__
A ->
B &
C .
D [ ]
答案
1=>A 2=>C
特殊运算符的重载
( )、[ ]、->、= 比较特殊,必须重载为成员函数,
即:第一个参数必须是用户定义类型
=运算符
这个其实就是,前边讲过的对象赋值
但,注意:
它未必是两个同类型对象间的赋值,也可以任意类型对本类型赋值。
T a,b;
a = b; // T& operator=(T& t);
a = 5; // T& operator=(int t);
未必要有返回值,可以是 void,那就不能支持连等赋值格式。
返回值未必是本类型引用,返回对象也可以,甚至任何类型都可以。
如果对象的成员数据有指针,默认的赋值操作可能有害:
i. 造成对象牵连
ii. 造成内存泄漏
[ ] 运算符
这个本意是数组取下标操作。不过,我们可以改为任何含义。
它既可以是左值,也可以是右值
T a;
a[1] = 5; // 用为左值,这是两个运算符:
// []一定要返回引用,才能支持赋值
b = a[1]; // 用为右值
如果不支持左值,可以返回任何需要的类型
class Punch{
public:
Punch(){ data = 0; }
void set(int day){
if(day<1 || day>31) return;
data |= 1 << (day-1);
}
bool get(int day){
if(day<1 || day>31) return false;
return data & (1 << (day-1));
}
bool operator[](int day){ return get(day); }
private:
unsigned int data; // 按二进制位记录每天打卡情况
};
[ ]用法,与 get() 是类似的(本质上是同一的)
Punch a;
a.set(5);
cout << a[5] << endl;
( )运算符
本意是函数运算,可以改为任意含义。
既可以左值,也可右值,所以一般返回引用类型。
参数的个数随便,也可以没有。
比如,常见的矩阵取值,赋值操作:
T a(3,4); //矩阵类型 3 行, 4 列
a(1,1) = 5; // 写入
cout << a(1,1) << endl; // 读取
-> 运算符
本意是对象的指针访问成员函数或数据。
可以重载,应用于对象,而不是指针,使得对象的行为看起来像指针。
对象伪装为指针。
目的:在恰当的时候,强迫回收资源,典型实现:智能指针。
一般配合 * 运算符重载联合使用。
// 用户类型
struct T{
void f() { cout << "T::f()..." << endl; }
};
// 服务于 T 的智能指针
class PT{
public:
PT(){ p = new T; }
~PT() {
cout << "free T ..." << endl;
delete p;
}
T* operator->(){ return p; }
private:
T* p;
};
可以比较和普通指针的操作:
T* p1 = new T;
p1->f(); // p1 对内存泄漏无能为力,它没有机会...
PT p2; // p2 是对象,伪装为 T* 类型,但又析构的机会....
p2->f();
实时效果反馈
1. 一般在什么情况下,需要重载对象赋值运算:__
A 对象中含有数组数据成员
B 对象中含有嵌套对象
C 对象中含有内部类
D 对象中含有指针数据成员
2. 重载 ->运算符
的场景,比较典型的应用是:__
A 避免空指针
B 避免对象赋值
C 希望指针变量生命期结束时,获得处理机会。
D 避免对象连体现象(指针牵连)
答案
1=>D 2=>C
const修饰符
const 的含义是:定常的,不变的。
它修饰的变量,可以避免无意或有意的修改。
代替宏的好处
程序中常量,用const,可以和宏定义一样,避免魔法数字。
比宏的优势在:
- const 有类型,编译器能介入更多的监督。
- const 定义的变量只有一处存储位置,而宏定义是替换,可能有多份拷贝。
- 在有些优化下,编译器根本不为const 真正分配内存,只作为编译立即数。
指针类型相关
const T p; T const p; 修饰被指向的内容
T * const p; 修饰的是指针本身
const T * const p; 指针和指向的内容都不可改变
函数相关
形参,修饰普通类型:
void f(const int x)
无意义,因为 x 变化对主调方无影响形参,修饰指针类型:
void f(const T* p)
无法通过形参指针影响实参对象形参,修饰引用类型:
void f(const T& p)
与指针类型的情况一样返回值
很少用 const,
当返回对象引用时,可防止接下来的链式操作,更改对象状态。
对象相关
修饰成员变量
则为常量,无法赋值,只能在初始化列表里初始化它
public:
T():x(100){} // 不允许出现 x = ...
private:
const int x;
修饰成员函数
void f() const { cout << y << endl; }
表示该函数不会修改对象的状态
当然,编译器不会允许它改状态的行为。
也不允许,调用其它非const修饰的成员函数
修饰对象(或者对象指针,对象引用)
该对象上只能执行 const 函数,或者读的动作,不能改写状态
(禁止的不一定是行为,有企图,或者说可能,就要禁止)
翻墙
const_cast<类型>( xxx )
进行强制转换
const T a;
a.g(); // g() 非const函数,不可调用
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
成员对象与封闭类
一个对象的成员数据如果是对象,则该对象称为成员对象。
此时,包含成员对象的这个类,称为封闭类(enclosed class)
是指外围那个类,不是被包含的那个
封闭类的构造函数
包含成员对象的类,初始化时有特殊性。
在进入初始化函数之前,它所内嵌的对象必须已经初始化完成。
那么,如果去初始化它呢?显然是通过该对象的构造函数完成。
如果该对象的类有多个构造函数,调哪个能控制不?
能! 使用 初始化列表
class A{
public:
A(){ cout << "A()" << endl; }
A(int a) { cout << "A(a)" << endl; this->a = a; }
private:
int a;
};
// enclosed class, init-table may be used
class B{
public:
B(int a, int b):x(a){ y = b; }
private:
A x;
int y;
};
初始化列表很有用,对普通类型也可以用初始化列表来初始化。
另一个必须使用的场景是:当有继承时,对父类的构造方法进行选择。
综合示例-智能指针
class T{
public:
class P{
public:
P(T* p):p(p){}
~P(){ if(p){delete p; p=NULL;} }
T* operator->(){ return p; }
private:
T* p;
};
T(){ cout << "T() .. " << endl; }
~T() { cout << "~T() .. " << endl; }
void f() { cout << "T::f() .. " << endl; }
};
T 是普通的类,但附带了一个仿真指针的内部类型 P
P 可以代替 T* 来完成各种操作
获得的额外好处是:可以自动调用析构,防止忘记释放 new T 分配的内存。
T::P p = new T();
p->f();
这里要分辨概念:
内部类,成员指针,成员对象,初始化列表
考虑在链表的 Node 中,使用 T::P 来代替 T*
通用的Node类型:
struct Node{
T* data;
Node* next;
}; // 好处是,不管用户数据类型是什么,此结构总是8字节
这样,对 T* 指向内存的管理十分危险,很容易泄漏。
使用智能指针代替:
struct Node{
T::P data;
Node* next;
};
此时,Node 仍然是 8 字节,但
data 不再是普通指针,而是一个对象。如果它销毁,会触发析构
此时,编译无法通过,因为虽然 Node 支持默认无参构造,但 data 不支持
所以,必须用初始化列表:
struct Node{
Node(T* t):data(t){next=NULL;}
T::P data;
Node* next;
};
测试:
Node a(new T());
Node b(new T());
a.next = &b;
实时效果反馈
1. 关于封闭类, 说法正确的是:__
A 封闭类就是内部类
B 封闭类就是包含内部类的外部类
C 封闭类就是,包含其它类对象作为数据成员的外部类
D 封闭类就是被其它类包含的类的对象
2. 哪个==不是==初始化列表的作用:__
A 封闭类的构造函数执行之前,先要初始化内嵌对象。
B 本类构造函数执行之前,先要执行父类的构造。
C 对本类的普通数据,也可以在初始化列表里指定初值。
D 为本类的静态数据指定初值
答案
1=>C 2=>D
智能指针之引用计数
我们用对象真能冒充指针吗?
换句话说,能冒充指针的所有操作,而不露出马脚吗?
这没有绝对,取决于我们应用的场景需要哪些功能。
比如,要不要支持 p++, 要不要支持 *p。
这些是明面上的功能,还有隐形的需求:
其中,最重要的是:如何处理多个指针指向同一对象?
T::P p(new T()); // 构造
p->f();
T::P q = p; // 拷贝构造
T::P s(new T());
s = q; // 对象赋值
内存图景如下:
可以使用著名的引用计数方案
就是在被指向对象中存放一个计数
每当有智能指针指向它,就加 1
每当有智能指针移开它,就减 1
当计数为 0, 释放对象内存,触发调用析构。。。。
class T{
public:
class P{
public:
P(T* p):p(p){ p->n++; }
P(P& t){ copy(t); }
~P(){ clear(); }
void clear(){
p->n--;
if(p->n == 0) { delete p; p=NULL; }
}
void operator=(P& t){ clear(); copy(t); }
T* operator->(){ return p; }
private:
void copy(P& t){ p = t.p; p->n++; }
T* p;
};
T(){ cout << "T() .. " << endl; n=0; }
~T() { cout << "~T() .. " << endl; }
void f() { cout << "T::f() .. " << endl; }
private:
int n; // 本对象的引用计数
};
微软的COM组件广泛使用了这个方案(当然,要复杂得多)
在有多线程,多进程的情况下,形势还要更严峻。
com内存泄漏也是十分棘手。。。
注意一个语法现象:
在类 P 中,使用了 T 中的私有数据成员(计数变量),
但,T 并未对其开放友元类权限。
这是内部类的访问特权,其原因很容易理解:
既然,把一个类包含在本类定义中,十有八九这两个类是同一个程序员维护的。
语法只有在具体、真实的使用场景中,才能显示它的朴实无华,简洁高效。
实时效果反馈
1. 智能指针,如何支持 *p 运算__
A 重载 * 运算符,返回目标对象的引用类型
B 重载 * 运算符,返回目标对象类型
C 重载 * 运算符,返回 this
D 重载 运算符,返回 this
2. 当在智能指针需要依赖引用计数时,该计数变量放在哪里合适?__
A 作为智能指针对象的成员
B 作为智能指针类的静态成员
C 作为智能指针指向的目标对象的成员
D 作为智能指针指向目标类的静态成员
答案
1=>A 2=>C
日期类型
日期,一般表述为某年某月某日。可以把这些数据捆绑为一个对象。
日期的常见运算是:
- 求两个日期差多少天
- 求某个日期 + x天 (或 - x天)后的日期
- 求某个日期是星期几
TDD,首先,写出将要如何使用它
Date a(2000,12,31);
cout << a << endl;
Date b(2001,1,1);
cout << b << endl;
cout << (b-a) << endl; // 求日期天数差
cout << Date(2022,1,15).week_day() << endl; //求星期几
设计实现思路
如何进行两个日期的求差?
一种想法,先求差 x 年,有闰年问题。。。
再求 相差 y 月,有大小月,闰月问题。。。
再求差 z 日
另一个方案:先把每个日期类型转换为:距离公元1年1月1日,经过的天数。
在对象中只存这个整数。需要表达为具体年月日时,再转换回来。
求差变成一个十分容易的问题。
再完善类的功能
尽量保持私有,尽量不用友元,但…
class Date{
public:
Date(){ k=0; }
Date(int y, int m, int d){ k = ymd_to_k(y,m,d); }
int year() { int y,m,d; k_to_ymd(k,y,m,d); return y; }
int month() { int y,m,d; k_to_ymd(k,y,m,d); return m; }
int day() { int y, m, d; k_to_ymd(k,y,m,d); return d; }
int week_day() { return k % 7; }
int operator-(Date& t){ return k - t.k; }
Date operator+(int x){ Date t; t.k = k + x; return t; }
Date operator-(int x){ return *this + (-x); }
friend ostream& operator<<(ostream& os, Date& t);
private:
static bool leap_year(int y){
return (y%4==0 && y%100!=0) || y%400==0;
}
static int ymd_to_k(int y, int m, int d);
static void k_to_ymd(int k, int& y, int& m, int& d);
int k; //记录距离 公元1年1月1日,过去了多少天。
};
cout << 对象
的功能只能重载为友元函数
ostream& operator<<(ostream& os, Date& t)
{
printf("%04d-%02d-%02d", t.year(), t.month(), t.day());
return os;
}
功能与实现的分离
同样的功能,我们可以设计不同的实现方式。
不同的实现方式,常常对应不同的数据结构。
所以,封装的含义是:隐藏实现细节,隐藏数据结构,暴露出功能性。
实时效果反馈
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)
设计原则:
- 能私有的,不要公开(尽量保持封装性)
- 能成员,不友元(封装性)
- 能静态函数,不成员函数(降低复杂性,降低依赖)
- 需要 const 时,大胆使用
- 成员函数,能const, 尽量const
年月日与 int 互转
合适的数据结构,会降低问题的复杂度。
复杂度,往往是表达形式引起的,而非问题本身固有复杂度。
透过现象看本质,是程序员永远的追求。
// 年月日 转为: 距离公元元年经过天数
int Date::ymd_to_k(int y, int m, int d)
{
int M[] = {0,31,28,31,30,31,30,31,31,30,31,30,31};
if(Date::leap_year(y)) M[2]++;
int k = 0;
for(int i=1; i<y; i++) k += 365 + leap_year(i);
for(int i=1; i<m; i++) k += M[i];
return k + d;
}
// 距离公元元年经过天数 转为: 年月日
void Date::k_to_ymd(int k, int& y, int& m, int& d)
{
int M[] = {0,31,28,31,30,31,30,31,31,30,31,30,31};
for(int i=1;; i++){
int t = 365 + Date::leap_year(i);
if(k<=t){
y = i;
break;
}
k -= t;
}
if(Date::leap_year(y)) M[2]++;
for(int i=1;;i++){
if(k<=M[i]){
m = i;
break;
}
k -= M[i];
}
d = k;
}
这两个函数可以保持私有,静态就足够了
月份天数常量可以提升为静态成员(节约空间,提高速度)
保持功能不变,改变实现,称为:重构(refactoring)
是持续提升软件质量的重要手段。
ostream << 中的const
重载 Date + int, Date - int
Date operator+(int x){ Date t; t.k = k + x; return t; }
Date operator-(int x){ return *this + (-x); }
cout << 的怪现象
Date c = a + 30;
cout << c << endl;
与下列比较:
cout << (a+30) << endl;
当对象为右值变量时,无法传入到非const形参中。
这就是为什么:形参变量是对象引用时,尽量const修饰
改为如下就可以了:
friend ostream& operator<<(ostream& os, const Date& t);
实时效果反馈
1. 为什么形参为对象引用时,尽量 const 修饰:__
A 如果不用const修饰,当实参为临时对象时,无法自动转换
B 这样可以避免传递对象拷贝
C 可以防止传入临时对象
D 可以支持链式操作
答案
1=>A
有理数类
有理数,就是可以表示为 p / q 的数(p, q 都是整数,互质)
使用有理数的好处是:可以不损失计算的精度,没有类似浮点数那样的舍入误差。
类的设计
需求是推动力。
提供哪些功能?
- 创建有理数
- 参与表达式的计算,返回新的有理数(+ - * /)
- 与整数的混合运算
- 显示
Rati a(30,50);
cout << a << endl; //显示 3/5 (约分后)
cout << Rati(2) << endl; // 构造重载
cout << Rati(1,3) + Rati(1,6) << endl;
cout << (1+Rati(1,2)) << endl;
类的实现
class Rati{
public:
Rati(){ p=1; q=1; }
Rati(int a, int b){
int g=gcd(a,b);
p=a/g; q=b/g;
}
Rati(int a){ p=a; q=1; }
Rati operator+(const Rati& t) const
{ return Rati(p*t.q+q*t.p, q*t.q); }
Rati operator+(int t) const
{ return *this + Rati(t); }
friend ostream& operator<<(ostream& os, const Rati& t);
friend Rati operator+(int t1, const Rati& t2);
private:
static int gcd(int a, int b){
if(b==0) return a;
return gcd(b,a%b);
}
int p; // 分子
int q; // 分母
};
Rati operator+(int t1, const Rati& t2)
{
return t2 + t1;
}
ostream& operator<<(ostream& os, const Rati& t)
{
cout << t.p;
if(t.q != 1) cout << "/" << t.q;
return os;
}
拓展
请试着完成 减法,乘法,除法运算。
如何支持很大的整数呢?
如果能用 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
字符串类
在许多高级语言中,字符串都是作为一个基本类型,或者对象来处理的。
c/c++中没有内置“串”这个类型,而是做了一个char* + ‘\0’结束的约定。
这种方案获得很高的效率
也带来很大不方便,而且,处理不好,就会内存泄漏。
struct Student{
char* name; // 相当凶险的定义!!
int age;
};
尝试自己定义一个 Str 类型
先规划它支持什么操作
也就是说,我们将如何使用它,期望它有什么样行为或表现。
(可以先降低对效率的要求,先实现功能再说)
需求
Str a("abc"); // 需要由 const char* 构造
Str b = "xyz"; // 需要由 const char* 构造
Str c = b; // 需要由 Str 拷贝构造
Str d;
d = a + b + c + "1234";
// Str + Str; Str + char*;
// char* + Str; Str = char*; Str = Str
cout << d << endl; // ostream << Str
Str 主要是对 char* 类型进行包装,负责监督、管理它的内存使用。
大体上就是要重载一些相关的运算符。
基本设计
让 Str 对象包含一个 char* 指针,指向 ‘\0’ 结束串,
并且,自动申请和释放内存空间。
Str 的主要职责:自动维护堆内存。
实现思路
通常的想法是,重载各路运算符。
也可以利用隐式转换
比如,
- 把类型 T 转为 Str 类型: 重载 拷贝构造 Str(T& )
- 把类型 Str 转为 T 类型: 重载成员函数 operator T()
实现
class Str{
public:
Str(){ ps = NULL; }
Str(const char* s){ copy(s); }
Str(const Str& t):Str(t.ps){}
~Str(){ clear(); }
Str& operator=(const Str& t){ return *this = t.ps; }
Str& operator=(const char* s) {
clear();
copy(s);
return *this;
}
Str operator+(const char* s) const {
int n1 = ps? strlen(ps) : 0;
int n2 = s? strlen(s) : 0;
Str t;
t.ps = new char [n1+n2+1];
t.ps[0] = 0;
if(ps) strcat(t.ps, ps);
if(s) strcat(t.ps,s);
return t;
}
operator const char*() const { return ps; }
// Str -> const char*
private:
void clear(){
if(ps){
delete [] ps;
ps=NULL;
}
}
void copy(const char* s){
if(s==NULL) {
ps = NULL;
return;
}
ps = new char[strlen(s)+1];
strcpy(ps, s);
}
char* ps; // 被包装
};
实时效果反馈
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)
与其它高级语言比较起来,c/c++ 是更注重效率的语言。
前边自定义的简陋 Str 类,当然还有许多问题(比如,功能不丰富)。
这里,我们着重讨论效率有关的问题。
考虑如下的循环:
Str a;
for(int i=0; i<10; i++){
a = a + i;
}
// a: "0123456789"
需要重载 operator+ (int)
可以,借用已经有的 operator+(const char*)
再,新定义构造 Str(int) 把 int 转化为 Str 对象
Str(int x){
ps = new char[30];
itoa(x, ps, 10);
}
// Str + int --> Str + Str --> Str + char*
Str operator+(int x) const { return *this + Str(x); }
这样,的确可以实现功能。
但,过于频繁地创建和销毁对象。
可以通过析构函数来观察这期间有多少对象出生:
~Str(){ clear(); cout << "~ "; }
为了减少对象折腾,我们可以提供 += 操作符,修改左值对象,而不是返回新的。
Str& operator+=(const char* s){
if(s==NULL) return *this;
char* p = ps;
ps = new char [len()+strlen(s)+1];
ps[0] = 0;
if(p) strcat(ps, p);
strcat(ps, s);
if(p) delete [] p;
return *this;
}
Str operator+(const char* s) const {
Str t(*this);
t += s;
return t;
}
使用的时候:
Str a;
for(int i=0; i<10; i++){
a += Str(i);
}
再仔细观察,这个时候,串的行为是:每次 += 都会新分配内存,老的释放。
很容易想到,能不能一次分配留点富裕,下次够用就不要重新分了。
这是个重要的设计:
预留空间的设计
去看一些工业库中类,经常会发现,len(或size)以外,还有个 cap,是什么意思?
对于我们的 Str 类, 就可以增加一个 cap,表示预留的 容量(capacity) 概念。
当需求小于这个容量,就直接原地“扩建”,
否则,重新分配空间(多申请些,作为新的预留)。老内容拷贝过来。
代码重构后
class Str{
public:
Str(int n){
if(n<10) n=10;
cap = n;
ps = new char [cap];
*ps = '\0';
}
Str():Str(10){}
Str(const char* s):Str((s?strlen(s):0)+10){
if(s) strcpy(ps, s);
}
Str(const Str& t):Str(t.ps){}
~Str(){
delete [] ps;
//cout << "~ ";
}
Str& operator=(const Str& t){ return *this = t.ps; }
Str& operator=(const char* s) {
int n = (s? strlen(s) : 0) + 1;
if(cap < n){
cap = n * 2;
delete [] ps;
ps = new char [cap];
}
if(s)
strcpy(ps, s);
else
*ps = '\0';
return *this;
}
Str operator+(int x) const { return *this + Str(x); }
Str& operator+=(const char* s){
if(s==NULL) return *this;
char* p = ps;
int n = strlen(ps) + strlen(s) + 1;
if(cap < n){
cap = n * 2; // 多分配些
ps = new char [cap];
strcpy(ps, p);
delete [] p;
}
strcat(ps, s);
return *this;
}
Str operator+(const char* s) const {
Str t(*this);
t += s;
return t;
}
// 为了隐式转换,// Str -> const char*
operator const char*() const { return ps; }
// 显示转换:从 Int 转为 Str
static Str from(int x){
Str t(30);
itoa(x, t.ps, 10);
return t;
}
private:
char* ps; // 被包装主体
int cap;
};
调用处轻微改变:
Str a;
for(int i=0; i<10; i++){
a += Str::from(i); // Str(int) 构造被占用了
}
拓展
如果不可惜浪费点存储,可以同时保存串长度和容量。
可以方便地找到串尾,方便反向遍历,串的拼接等动作,提高效率。
实时效果反馈
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
继承
继承与组合(聚合)
继承一种重型的解决方案,在设计的时候要充分估计到需求可能的变化。
如果当初考虑不足,等到继承了很多类以后,再动祖先类就相当困难了。
仅仅为了达到重用的目的,
优先考虑组合或聚合的方案,其次才考虑用继承。
如果,B 类继承了 A 类,则 B 类具有了 A 类的全部能力。
此时,A类称为: 基类(base class) ,父类,超类
相对地,B类称为:派生类(derived class),子类,继承类
重用已有的功能
我们使用继承的最基本诉求是:重用已经存在的功能。
当软件变得庞大的时候,重用变得苛刻。
尽量不改动已经存在模块(即便是有源代码)
所谓:只扩展(extend),不修改
【示例】
Cnt 类的基本情况
class Cnt{
public:
Cnt(){n=0;}
void inc(){n++;}
int get(){return n;}
private:
int n;
};
比如,增补一个限制最大功能(最大x封顶),
还要顺便支持一下链式操作
Cnt2 a(3);
a.inc().inc().inc().inc();
cout << a.get() << endl;
注意,不可以修改Cnt类
可以才用两种方案:
继承
class Cnt2: public Cnt{
public:
Cnt2(int m){ max = m; }
Cnt2& inc(){
if(get() >= max) return *this;
Cnt::inc();
return *this;
}
private:
int max;
};
组合
class Cnt3{
public:
Cnt3(int m){ max = m; }
Cnt3& inc(){
if(cnt.get() >= max) return *this;
cnt.inc();
return *this;
}
int get() { return cnt.get(); }
private:
int max;
Cnt cnt;
};
继承的优势
继承是一种 is-a 关系,组合是has-a关系
这样,在传参的时候,有兼容性
也就是说,子类可以冒充(顶替)父类去完成任务
void f(Cnt& x) // 这里,需要的是:Cnt类型
{
cout << x.get() << endl;
}
我们可以出入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
继承后的权限
B类继承A类后,权限比“陌生人”访问A类,更多一些。
class A{
public:
// 此部分,任何人都可以访问到
protected:
// 此部分,亲疏有别,继承人可以访问到,陌生人不可
private:
// 此部分,除了自己和特殊授权的友元,其它人都不能访问到
};
考虑到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函数
class A{
public:
void f(){}
};
class B:A{};
// 当外界如下访问的时候:
B x; x.f(); // f() 是private,访问不到
继承与友元
友元关系是单向的。
友元关系不能被继承。
class A{
public:
friend void fa(A& t);
private:
int a;
};
class B: public A{
public:
friend void fb(B& t);
private:
int b;
};
void fa(A& t){ t.a++; }
void fb(B& t){ t.b++; t.a++; } // 不可以,必须在A类中授权
单独放开权限:
class B; // 注意这个前向声明,否则编译不过
class A{
public:
friend void fa(A& t);
friend void fb(B& t);
private:
int a;
};
继承与静态
静态成员变量,只有一份。
类内声明,类外定义(初始化)
无论通过类名访问,还是通过对象访问,都是同一个东西,
而且,只有一份。
所以,根本不存在继承不继承的问题。
实时效果反馈
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
多继承与二义性
多重继承
比较另类的,c++支持多继承,大多数高级语言不支持。
这样做的好处,更方便地支持代码重用。
另外,与现实世界一致(同一对象,多重身份):
比如:一位参赛的学生,既是学生,也是运动员。
许多高级语言为了解决这个问题,引入了接口的概念,c++当然能模拟出接口
同时,也支持了具体类的多继承,这有一定风险。
解决冲突的方案:
用类名限定
体育特长生 x("zhang");
x.运动员::编号 = ...
x.学生::编号 = ...
派生类中再定义:编号,覆盖掉父类的编号体系。
在新编号中可以选用某个父类的编号,
也可以重新设计编号,从而完全隐藏了父类的编号体系。
菱形继承问题
菱形继承指的是:从不同分支,继承了同一祖先,这样,该祖先的数据就存在多份
在这种情形下,同样会出现歧义。
解决的方法还是用类名限定路径,或者做覆盖处理。
class A{
public:
int a;
};
class B1: public A{};
class B2: public A{};
class C:public B1, public B2{};
使用方法:
C x;
x.B1::a = 10;
x.B2::a = 20;
cout << x.B1::a << "," << x.B2::a << endl;
虚继承
我们可以指定公共基类不要重复继承。
在指定继承方式的时候,增加修饰 virtual,称为虚继承。
这时,被继承的基类称为:虚基类。
class A{
public:
int a;
};
class B1: virtual public A{};
class B2: virtual public A{};
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
继承中的构造函数
总结构造函数:
- 语法:与类同名,无返回值(不是void)
- 作用:初始化对象的成员数据,使对象进入“有效状态”
- 调用时机:当对象出生时,自动被调用。
- 类别:默认的无参构造,一般重载构造,拷贝构造
- 与普通成员函数区别:不能随意调用,初始化列表
如何调用父类的构造函数
按c++规定,对象的数据一定要初始化
当有继承后…
子类对象包含了父类的数据,这部分数据可能是private,子类访问不到,
只能由父类自己的构造函数来完成初始化。
因此,执行任何子类构造函数之前,总是先执行父类的构造函数
执行哪个? 可以通过初始化列表来控制。
struct A{
A(){ cout << "A::A()" << endl; }
};
struct B:A{
B(int x){ cout << "B::B(int)" << endl; }
};
// 调用处:
B x(100);
假如A类中,有多个版本的构造函数,我们可以在B类构造函数的初始化列表中,
选择调用哪一个。
如果不指定,则调用无参构造。
struct A{
A(){ cout << "A::A()" << endl; }
A(int a, char* b){ cout << "A::A(int, char*)" << endl; }
};
struct B: public A{
B():A(10,"abc"){ cout << "B::B()" << endl; }
B(int x){ cout << "B::B(int)" << endl; }
};
int main()
{
B x(100);
B y;
return 0;
}
注意:
子类的构造函数之初始化列表,只能控制怎么调用父类的构造,不能控制更远的“祖父”类,
那个是父类的初始化别表的职责。
至此,初始化列表的职责:
- 基类初始化
- 内嵌对象的初始化
- 本类 const 成员初始化
c++11标准的拓展
如果父类有多个版本的构造函数,
按照子类完全顶替父类的原则,子类什么都不做,即可具有父类全部能力。
然而,构造函数并没有被子类继承,如果要获得父类的构造能力,子类必须当二传手。
struct A{
A(){ cout << "A::A()" << endl; }
A(int){ cout << "A::A(int)" << endl; }
A(int, int){ cout << "A::A(int,int)" << endl; }
};
struct B:A{
B():A(){}
B(int a):A(a){}
B(int a, int b):A(a,b){}
};
int main()
{
B a;
B b(1);
B c(1,2);
return 0;
}
当构造函数版本很多的时候,这种“二传手”很枯燥,
c++11 引入了继承基类构造函数的方法:
struct A{
A(){ cout << "A::A()" << endl; }
A(int){ cout << "A::A(int)" << endl; }
A(int, int){ cout << "A::A(int,int)" << endl; }
};
struct B: public A{
using A::A; //所有A类构造函数全继承
};
这其实是个特例,对普通的成员函数也可以这样来避免覆盖问题。
struct A{
void f() { cout << "A::f" << endl; }
};
struct B: public A{
using A::f;
void f(){ cout << "B::f" << endl; }
};
int main()
{
B a;
a.f();
a.A::f();
return 0;
}
这是有用的,有时候,孙子类可能想越过父类,访问祖父类的 f 方法。
虚基类的初始化
多继承的时候,虚基类的数据只有一份,谁来控制它的初始化呢?
c++规定,由最后一个继承者来控制虚基类的初始化。
struct A{
A(){ cout << "A::A()" << endl; }
A(int){ cout << "A::A(int)" << endl; }
};
struct B1: virtual public A{
B1(){ cout << "B1::B1()" << endl; }
};
struct B2: virtual public A{
B2(){ cout << "B2::B2()" << endl; }
};
struct C: public B1, public B2{
C():A(33){ cout << "C::C()" << endl; }
};
int main()
{
C x;
return 0;
}
注意,构造函数的执行顺序还是A::A 最先,
但其选择权及喂入的参数在C类的初始化列表中才有效。
实时效果反馈
1. 当有继承关系时,关于构造函数的说法,正确的是:__
A 子类的构造函数负责初始化自己的成员数据,以及父类的成员数据
B 子类的构造函数只负责初始化自己的成员数据,父类的由其默认构造完成
C 子类的构造函数只负责初始化自己的成员数据,但可以在其初始化列表选择父类构造
D 子类构造函数要负责自己,以及所有祖先类的成员数据的初始化
2. 以下,哪项==不是==初始化列表的职责:__
A 选择父类的构造函数,并传入参数
B 初始化本类嵌入的对象
C 初始化本类的 const 成员数据
D 初始化本类的 static 成员数据
答案
1=>C 2=>D
继承下的内存模型
当没有继承的时候,
对象只包含成员数据(不包括成员函数,不包括静态成员数据)
当有继承的时候,
子类对象size >= 父类对象size
父类的private数据也会包含在子类的对象中(但通过父类的函数来访问)
如何探索内存结构
有些IDE提供了辅助工具,比如,vs
手动输出变量的地址进行分析。
struct A{
int a;
};
struct B1:A{
int b1;
};
struct B2:A{
int b2;
};
struct C:B1,B2{
int c;
};
// 调用方法:
C x;
cout << sizeof(x) << endl;
cout << &(x.B1::a) << endl;
cout << &(x.b1) << endl;
cout << &(x.B2::a) << endl;
cout << &(x.b2) << endl;
cout << &(x.c) << endl;
运行结果:
可以分析出,此时的内存模型是:
可以采用同样的方法来观察,虚继承的情况:
struct A{
int a;
};
struct B1:virtual A{
int b1;
};
struct B2:virtual A{
int b2;
};
struct C:B1,B2{
int c;
};
int main()
{
C x;
cout << sizeof(x) << endl;
cout << &x << endl;
cout << &(x.B1::a) << endl;
cout << &(x.b1) << endl;
cout << &(x.B2::a) << endl;
cout << &(x.b2) << endl;
cout << &(x.c) << endl;
return 0;
}
当同时还有组合或聚合
组合或聚合都属于本类的数据,在本类数据区处存放。
不同的是,
组合(也称为:内嵌对象)时,包含整个内嵌对象。
聚合时,只包含一个指向聚合对象的指针数据。
实时效果反馈
1. 当有继承时, 子类对象内存模型,说法正确的是:__
A 子类对象只包含本类型数据,但包含直接父类数据区的指针。
B 子类对象包含本类型非静态成员数据,以及所有祖先类的非静态成员数据
C 子类对象仅仅包含本类型的所有成员数据。
D 子类对象的大小与继承方式无关
2. 如果子类中还聚合了T类的对象,说法正确的是:__
A 子类数据包含T类数据,并负责T类对象的初始化
B 子类数据包含指向T类对象的指针,一般不负责管理T对象的生存期
C 与子类对象继承T类后的内存模型相同
D 子类数据包含指向T类对象指针,并在初始化列表,对T类对象初始化
答案
1=>B 2=>B
指针的泛化
指针泛化指的是:基类的指针,指向子类的对象。
通过该指针可以调用所有基类的功能,但不能调用子类的功能。
class A{
public:
void f() { cout << "A::f()" << endl; }
private:
int a;
};
class B: public A{
public:
void f() { cout << "B::f()" << endl; }
private:
int b;
};
int main()
{
B x;
x.f();
A* p = &x; // 指针泛化
p->f();
return 0;
}
逻辑上的可行性
子类对象可以被看作:特殊的父类对象。
这正是 is-a 关系的体现。
这与现实世界一致:猫,狗,鸭子都可以当作动物来对待。
子类对象拥有父类的全部能力,此外,还增添了新的特征。
泛化,就是忽略子类对象的特殊性质,而讨论它从父类继承的一般性质。
物理上的可行性
从内存上来分析,子类包含了父类的所有成员数据。
所有,子类的一部分,完全可以作为父类对象来使用,逻辑上,与父类自己生成的对象没有什么差异。
那么,多继承的情况呢?
struct A{
void f() { cout << "A::f()" << endl; }
int a;
};
struct A2{
int a2;
};
struct B: A, A2{
void f() { cout << "B::f()" << endl; }
int b;
};
int main()
{
B x;
cout << &x << endl;
A2* p = &x;
cout << p << endl;
return 0;
}
泛化的目的
对象不按照原来的类型来使用,为什么要泛化它呢?
泛化的目的是为了多态—-相同的调用,调用到不同的函数。
可以简单地理解为:把一些不同的东西做统一的处理(当然,它们有共性)。
泛化就是:甩开特性,只看共性。
拓展问题
与泛化相反,“指针特化”可以不?
脊椎动物 a;
猫* p = &a; // 这会编译不过
// 显然,a不是猫,如果通过p调用猫类方法,可能会失败!!
一定指针才可以泛化吗?对象可不可以泛化?
一般很少这么做,但可行。
struct A{int a;};
struct B:A{int b;};
int main()
{
A x;
B y;
y.a = 100;
x = y;
cout << x.a << endl;
return 0;
}
实时效果反馈
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
多态
重载可能是:overload, 或者 override
一般,前者指静态重载,就是编译期间的重载。
比如 int max(int, int) 和 int max(int, int, int) ,仅仅函数名碰巧相同而已。
也有称为静态多态的。
后者指动态重载,就是说,编译期间也确定不了调哪个函数。
动态重载,也就是我们常听说的多态。
面向对象的特征有:封装,继承,多态等。
多态可以说是面向对象的华彩乐章,是面向对象的强大武器。
发现多态现象
多态,即:动态重载,也就是说,在编译期间无法确定调用哪个函数,
直到运行时才能确定下来。
struct A{
virtual void f(){ cout << "A::f" << endl; }
};
struct B1:A{
virtual void f(){ cout << "B1::f" << endl; }
};
struct B2:A{
virtual void f(){ cout << "B2::f" << endl; }
};
int main()
{
A* p;
int x;
cin >> x;
if(x<5)
p = new B1();
else
p = new B2();
p->f(); // 如何编译这个 f()?
return 0;
}
上边的f(),没有在编译期间确定下来。
根据运行时,指向的对象不同,调用了不同的f()
有何实用价值?
为了把不同的东西当相同的类型处理。以便软件的“蓝图化”设计。
所谓高层编码,可以不管具体细节。
假设有一个桌面绘图软件,我们用伪代码实现重绘功能:
class 图形{
public:
virtual void paint(画刷& br); //重绘图形
....
private:
Point 当前位置;
....
};
class 圆形:public 图形{
public:
virtual void paint(画刷& br);
...
};
class 三角形:public 图形{
virtual void pain(画刷& br);
};
////// 窗口管理中:
void redraw(图形列表 L){
br = 创建画刷();
for(L::Iter i=L.begin(); i!=L.end(); i++){
i->paint(br);
}
销毁画刷(br);
}
L是图形列表,持有的元素是各种不同类型的指针(圆形*
, 三角形*
等 )
我们要对这些不同类型的对象大喊:“绘制自己!”,这该如何实现。
如果没有多态,恐怕:
if(i is 圆形指针) i->圆的绘制(br);
else if(i is 三角形指针) i->三角形的绘制(br);
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
虚函数表
如何形成动态多态的效果?
通过:父类虚函数加 virtual 修饰,子类中重写,指针泛化。
本节探究一下多态的实现方法。
观察对象的大小
struct A{
void fa(){}
void fb(){}
void ga();
void gb(int);
int a;
};
struct B{
virtual void fa(){}
virtual void fb(){}
void ga();
void gb(int);
int a;
};
int main()
{
A a;
B b;
cout << sizeof(a) << endl;
cout << sizeof(b) << endl;
}
我们可以看到,当增加了 virtual 修饰,该类的实例增加了 4 个字节,这是个指针。
该指针在对象的最开始处,指向一个虚函数表,
就是记录了,该类中可访问的所有虚函数的函数指针的表。
当调用虚函数时,首先拿到这个对象里的指针,找到表,调用表中的虚函数。
如果是普通函数,则不通过这个表,直接静态编译。
那么,当有继承的时候,如何实现的多态呢?
继承下的虚函数
struct A{
virtual void fa(){ cout << "A:fa()" << endl; }
virtual void fb(){ cout << "A:fb()" << endl; }
void ga(){ cout << "A:ga()" << endl; }
void gb(int);
int a;
};
struct B:A{
virtual void fa(){ cout << "B:fa()" << endl; }
void ga(){ cout << "A:ga()" << endl; }
int b;
};
int main()
{
A* p = new B(); //指针泛化
p->fa();
p->fb();
p->ga();
return 0;
}
其运行结果:
fa 是虚函数,所有实现了多态的效果。
fb是虚函数,但子类没有覆盖,所以还是调用A::fb()
ga不是虚函数,不通过多态机制,直接编译为:A::ga()
虚函数表,每个类有唯一的一份。
如果类中有虚函数,则其对象头部含有一个指向本类虚函数表的指针。
虚函数中列出了从本类实例调用的所有虚函数的函数指针。
拓展
c++11 中引入了 override, final 做更明确的指示。
防止手抖,定义了新的虚函数,而不是覆盖父类的。
virtual void fa() override; // 明确要覆盖父类同名函数
而final则表明,本函数不许覆盖。
void f() final;
实时效果反馈
1. 要形成动态多态效果,哪个==不是==必须的:__
A 父类中定义了虚函数
B 子类覆盖父类中的虚函数
C 父类指针,指向了子类对象,并且调用虚函数
D 在子类覆盖虚函数中,再去调用父类的虚函数
2. 关于虚函数表,说法正确的是:__
A 所有类都对应同一个虚函数表
B 所有含有虚函数的对象都对应单独的虚函数表
C 每个含有虚函数(包括继承来的)的类对应唯一的虚函数表
D 包含虚函数的类实例化时,临时创建虚函数表
答案
1=>D 2=>C
虚析构函数
虚函数表会占用内存,
调用虚函数有查表动作,增加处理时间。
但,有些场合,使用虚函数是必要的。
比较典型的场合:虚析构函数。
应用场景
考虑如下场景:
Shape 类有许多派生: Circle, Rect, Triangle 等
通过Shape指针数组来管理多个图形对象。
这里出现了指针泛化现象
现在来观察,回收对象内存时的景象。
struct Shape{
Shape(){ cout << "create Shape" << endl; }
~Shape() { cout << "destroy Shape" << endl; }
};
struct Circle:Shape{
Circle(){ cout << "create Circle" << endl; }
~Circle(){ cout << "destroy Circle" << endl; }
};
struct Rect:Shape{
Rect(){ cout << "create Rect" << endl; }
~Rect(){ cout << "destroy Rect" << endl; }
};
struct Triangle:Shape{
Triangle(){ cout << "create Triangle" << endl; }
~Triangle(){ cout << "destroy Triangle" << endl; }
};
int main()
{
Shape* x[3];
x[0] = new Circle();
x[1] = new Rect();
x[2] = new Triangle();
for(int i=0; i<3; i++) delete x[i]; //必须写,否则泄漏
return 0;
}
来看看执行结果:
这里,没有准确调用具体类的析构函数,可能会引发问题。
实时效果反馈
1. 关于析构函数, 说法正确的是:__
A 析构函数无参,没有返回值,无重载版本
B T* 指针变量离开作用域,自动调用 T 的析构
C 析构子类对象,不会自动调用父类的析构
D 函数返回的临时对象不会自动调用析构
2. 把析构函数做成虚函数,好处是:__
A 节省内存
B 更快速完成清理工作
C 防止内存泄漏
D 当出现指针泛化时,调用到对象真实类型的析构函数
答案
1=>A 2=>D
RTTI机制
RTTI 即: RunTime Type Identification (运行时类型识别)
在指针泛化后,多态的功能很强大,能满足大多数情况的应用。
但,有时,我们希望获得指针指向的对象的真实类型。
这就需要RTTI了。
c++不具备类似java的完备的反射机制,但RTTI可以完成很多任务。
typeid
typeid 是操作符,它可以返回对象的真实类型。
返回值是一个type_info结构,支持一些运算,有name()函数。
可以比较两个type_info是否相同,从而知道是否为同一个类型。
以下代码需要include:
#include <typeinfo>
struct A{virtual void f(){}};
struct B:A{int x;};
int main()
{
A* p = new B();
cout << typeid("abc").name() << endl;
cout << typeid(123.5).name() << endl;
cout << typeid(p).name() << endl;
cout << typeid(*p).name() << endl;
cout << typeid(B).name() << endl;
return 0;
}
名字比较奇怪,一般不直接使用。
要保证找到真正的子类,A必须含有虚函数,因而对象中含有虚函数表的指针。
判断对象的真实类型惯用法:
A* p = ....
if(typeid(*p)==typeid(B)) ...
向下转型
泛化,是把一个具体类型的对象指针转为:更一般,更抽象类型的指针(向上转型)
向上转型是自动的,有的时候,也会有向下转型的需要:
A* p = ....
B* q = (B*)p; // 这是个危险的方案!!因为有多继承
我们知道,当有多继承的时候,父类指针的值并不一定等于子类对象的首地址。
所以,强制认为该值就是子类对象的开始位置是武断的!
更安全的向下转型方法:
struct A{
virtual void f(){ cout << "A::f()" << endl; }
};
struct B{
virtual void g(){ cout << "B::g()" << endl; }
};
struct C:A,B{
void h() { cout << "C::h()" << endl; }
};
int main()
{
B* p = new C();
if(typeid(*p)==typeid(C)){
C* q = dynamic_cast<C*>(p);
q->h();
}
return 0;
}
实时效果反馈
1. 关于typeid, 说法正确的是:__
A 它是系统函数,返回变量的类型
B 与sizeof一样,是运算符,返回的是type_info结构体
C 是运算符,返回类型名字
D 是函数,返回类型的标识号
2. 泛化是向上转型,那么,如何向下转型?__
A 编译器会自动转换
B 强制转换:
(类型)x
C dynamic_cast<类型>(x)
D 不允许
答案
1=>B 2=>C
抽象类型
纯虚函数:只约束形式,但不去实现的函数。
virtual void f() = 0; // 只声明,不去实现
只要包含一个纯虚函数,就无法实例化。
纯虚函数只能等待子类去覆盖它。
含有纯虚函数的类,无法实例化,其用途:
- 为子类设置统一的约束。强制子类型必须实现它。
- 阻止对本类型的实例化
- 它的指针类型可以用作泛化的指针
储蓄账户的示例
在储蓄账户类里,
完成年利率的计算过程,并且不允许子类去覆盖。
计算过程中,用到月利率,这个是不确定的,银行与具体的用户签订了不同的协议。
(比如,关联各种优惠、促销等活动)
#include <iostream>
using namespace std;
class SavAcc{
public:
SavAcc() { balance = 0; }
virtual int year_interest() final{
int sum = balance;
for(int i=0; i<12; i++){
double k = sum * mon_inter_rate(i) / 100;
sum += (int)( k + 0.5);
}
return sum - balance;
}
// 强制子类必须完成
virtual double mon_inter_rate(int x) = 0;
void deposit(int x){ //存款
balance += x;
}
int get_balance() { return balance; }
protected:
int balance; //余额
};
class T1SavAcc: public SavAcc{
public:
virtual double mon_inter_rate(int x) override {
if(x<3) return 0.3;
return 0.25;
}
};
class T2SavAcc: public SavAcc{
public:
virtual double mon_inter_rate(int x) override {
return 0.3;
}
};
int main()
{
T1SavAcc a;
a.deposit(10000);
cout << a.year_interest() << endl;
cout << a.get_balance() << endl;
return 0;
}
实时效果反馈
1. 如何强制子类必须去覆盖基类的某个虚函数, 较好的做法是:__
A 在文档中声明,提醒程序员注意。
B 虚函数不实现,定义为纯虚函数的形式
C 虚函数后加 override 修饰
D 虚函数后加 final 修饰
2. 如何防止子类覆盖父类的虚函数?__
A 声明为const 类型的函数
B 声明为 override 类型的函数
C 声明为 final 类型的函数
D 声明为纯虚类型的函数
答案
1=>B 2=>C
接口
如何声明接口
接口在形式上,就是一个普通的类。
习惯上,所有成员函数都是public的纯虚函数,
不能被实例化。
与抽象类相比较,接口抽象程度更高,只提供约束,不真正实现任何东西。
也可以说:
接口只是一份契约,只是一份产品规格说明书。
c++中,并没有对接口和抽象类严格区分,惯例上:
- 只包含纯虚函数
- 不要定义成员数据,但可以有静态常量
- 定义虚析构函数
- 不要定义构造函数
示例
struct IFlyable{
virtual void fly() = 0;
virtual ~IFlyable(){}
};
struct IManmade{
virtual double price() = 0;
virtual ~IManmade() {}
};
class Bird: public IFlyable{
public:
virtual void fly() override {
cout << "I am a bird, flying..." << endl;
}
};
class Plane: public IFlyable, public IManmade {
public:
virtual void fly() override {
cout << "a plane can fly..." << endl;
}
virtual double price() {
return 112345.6;
}
};
void f(IFlyable& x)
{
cout << "beijing--->shanghai" << endl;
x.fly();
}
void g(IManmade& x)
{
if(x.price() < 1000)
cout << "cheap!" << endl;
else
cout << "expensive!" << endl;
}
int main()
{
Plane a;
f(a);
g(a);
return 0;
}
只用于标识的接口
不需要子类去实现什么,只是表明对象有某个性质,或属于某个类别。
比如,java中的 Serializable
struct IMyTag
virtual ~IMyTag() = 0; // 阻止创建实例,并不需要子类覆盖
};
IMyTag::~IMyTag(){} // 必须有实现,与普通函数override不同
实时效果反馈
1. 关于c++接口类型, 说法正确的是:__
A c++的接口是特殊的类,它的实例不占内存。
B c++把最抽象的类当作接口来使用
C c++的接口指的就是虚基类
D c++中,可以继承多个接口,但只能继承一个实体类。
2. 虚析构函数与普通的虚函数的多态有何不同?__
A 虚析构函数子类必须覆盖,否则编译不过
B 通过泛化指针删除对象时,先调用真实类的析构,然后自动依次调用祖先类析构。
C 先调用祖先类析构,再依次调用子孙类析构。
D 不能定义纯虚析构函数
答案
1=>B 2=>B
异常处理
怎么对待错误?
尽可能早地,发现不正常的情况,避免更大损失。
int fac(int n){
if(n<=0) return 1; // 不好,这不是“健壮”,这是“姑息”
....
}
某程序员曰:如果感觉不妙,就让它崩溃吧!
异常是管理错误的一种方式。
常规的管理方法:依赖返回值的检查。
有的场合不合适:
- 返回值位置被有效信息占用,没法再表达错误信息(带宽不够?)
- 多层的调用,层层检查,最高层才处理。
- 有些函数没有返回值,比如:构造函数,析构函数
c++异常机制
try{
if(...) throw 类型 x; //也可能是调用的函数链中throw了异常
some_action() // 如果有异常,此后被跳过
}
catch(类型& e){
// 处理此类异常
}
catch(类型& e){
cout << e.what() << endl; // 打印异常原因
}
catch(...){
// 所有未捕获类型
}
代码中,通过 throw 抛出某个异常对象。
throw 后面可以是任何数据类型,甚至可以是 int double 类型
比如:throw -99; throw "bad index"; throw Point(2,5);
在其调用链的某处,用 catch 语句捕获。
异常的类型支持语言内置类型以及复合类型(可自定义)
与其迁就,不如崩溃!
应当精准捕捉错误,非预见的错误不要处理,暴露出来是好事。
异常的用途
报告错误
有时,条件限制,我们无法通过返回值来报告错误,或意外发生的情况。
(比如,返回值已经被其它值占用;
函数要保持历史兼容,现在需要扩展功能;
接口是别人定的,我们无法修改)
快速流程转移
fa 调用 fb, fb 调用 fc …. fn 中发现错误,但,只有 fa 知道如何处理。
实例—自己定义数组类型
解决两个问题:
数组可能会很大,需要new, 但不要忘记了 delete
int* ar = new int [1000*1000];
ar[99] = ...
...
if($%#$$@$@$) return; //很容易忘记“借钱”的事
...
delete [] ar;
- 数组访问越界的情况,不能容忍,需要抛出异常,立即崩溃!
class MyArray{
public:
MyArray(int size){
pData = new int [size]; // 可能会引发异常
this->size = size;
}
int& operator[] (int x){
if(x<0 || x >=size) throw "bad index";
return pData[x];
}
~MyArray(){
delete [] pData;
}
private:
int* pData;
int size;
};
使用方法:
try{
MyArray a(10);
a[5] = 555;
cout << a[5] << endl;
}
catch(const char* e){
cout << "ERR: " << e << endl;
}
catch(bad_array_new_length& e){
cout << "ERR: " << e.what() << endl;
}
catch之后
如果try中有分配资源,异常发生后,需要清理资源
当后续不知道如何处理,可以再次抛出异常: throw;
声明函数可能抛出的异常
func(形参) throw(类型1, 类型2,...) {
函数体
}
则,该函数仅能抛出异常类型1,类型2,及其子类型
如果一个函数保证不会抛出异常:
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
标准库中的异常类
c++标准库中定义了异常体系,exception是所有异常的总根。
logic_error: 主要是指逻辑上问题,这是可以避免的,属于程序设计上的漏洞
runtime_error: 运行时错,与用户输出有关,一般无法避免
比如:等待用户输入一个整数,结果,用户不小心输入了 “abc”
- 还有些,语言一级的异常,比如:bad_alloc bad_typeid 等
new, delete, typeid 等内置的运算符会引发这些异常
我们可以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
开发阶段中,就是要“小题大做”,“沾火就着”,对任何可疑之处,直接崩溃!
long long fac(int x)
{
if(x<0) throw invalid_argument("factorial: x<0");
if(x==0) return 1LL;
return fac(x-1) * x;
}
实例2
试着截获:new 分配异常(可能请求数量太大或太小)
int* a = new int[-10];
catch 的时候,用什么类型
try{
int* a = new int[-10];
}
catch(bad_array_new_length& e){
cout << e.what() << endl;
}
也可以用它的祖先类:
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
自定义异常类型
为了更精细地表达异常信息,可以自己定义异常类型。
虽然,任何类的实例都可以作为 throw 的对象,但继承 exception或其派生类是惯例。
最常被继承的是:logic_error
自己定义的异常类型一般要覆盖 what() 虚函数,
以表明产生异常的原因。
如果,希望更详尽地描述异常信息,可以增加自己的数据,记录更丰富的信息。
比如,如果数组越界了,我们可能想知道,到底是什么样的错误索引,导致了越界。
一般,异常的处理层次没有那么多的时候,可以用struct,所有成员都public
自定义注意
- 覆盖 what() ,以表明原因
- 按惯例,有个传入const char* 的构造函数
- 预备今后的继承和多态用法,提供一个虚析构函数
- 为了正确地复制对象,提供拷贝构造和对象赋值。
struct IndexError: logic_error{
IndexError(int min, int max, int x):logic_error(""){
sprintf(err,"index must between %d-%d, but get: %d",
min, max, x);
}
virtual const char* what() const throw() override{
return err;
}
char err[100];
};
class MyArray{
public:
MyArray(int size){
pData = new int [size];
this->size = size;
}
int& operator[] (int x){
if(x<0 || x >=size) throw IndexError(0,size-1,x);
return pData[x];
}
~MyArray(){
delete [] pData;
}
private:
int* pData;
int size;
};
int main()
{
try{
MyArray a(20);
a[9] = 555;
cout << a[25] << endl;
}
catch(IndexError& e){
cout << e.what() << endl;
}
}
exception及其大部分子类,带参构造(const char*),填入的信息就是what() 返回的信息。
如果希望更丰富细腻的信息,一般 2 种方案:
如前边示例,自己预留空间,计算并保存异常信息,然后重载what(),显示信息。
可以利用父类构造函数传入一般的异常信息,更详尽参数通过定义成员函数获得:
比如:
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
c++ 标准支持try…catch 结构,却没有提供对 try..finally 的支持。
// Java 的模板代码
x = 分配资源;
try{
使用资源。。。。。;
...return xx;
...break;
...throw 异常;
...正常。。
}
finally{
释放资源(x);
}
有些编译环境扩展了标准c++,比如微软的VS,提供了相应的TRY…FINALLY结构。
标准c++鼓励使用对象的析构函数来回收资源,以防止资源损失
(不仅仅是内存,还有文件句柄,socket等)
RAII = Resource Acquisition Is Initialization “资源获取即初始化”
利用对象的析构自动执行的优势,实现资源的自动化管理。
广义地说,资源也包括任何必须成对儿出现的动作。
比如,某规范约定,
开始,需要调用 hello()
结束,需要调用bye()
关键问题:如何保证,只要开始了,任何复杂局面下,
这个bye()
都一定会获得执行的机会。
常规手段的弱点
a = 分配资源();
...
if(出错) return; // 这里忘记了回收资源
释放资源(a);
for(int i=0; i<=end; i++){
if(i==0) 分配资源();
if(出错) break; // 忘记了回收资源
if(i==end) 回收资源();
}
分配资源();
...
if(出错) throw MyErr("#$@$#%@"); // 忘记了回收资源
...
回收资源();
RAII 的威力
struct A{
A() { cout << "acquire resource..." << endl; }
~A() { cout << "free resource...." << endl; }
// 资源指针
};
void f()
{
cout << "begin..." << endl;
A a;
throw "bad argument";
cout << "after exception.." << endl;
} // 此处调用析构
int main()
{
try{
f();
}
catch(...){ }
return 0;
}
可以看到,当栈对象所在函数throw了异常以后,析构函数仍能正确调用,
这是资源能够正确释放的必要保证。
有c++建议认为:程序中,应该尽量避免硬编码的:delete 语句,
尽量使用RAII来完成资源的自动回收。
这种风格,可以取得 try…finally 的效果,并且有更高的执行效率。
智能指针就是RAII 策略的典范。
充分利用对象的构造和析构函数,完成配对儿特征的操作。
实时效果反馈
1. 在栈上创建的对象,当离开其作用域时,说法正确的是:__
A 当因为异常而离开作用域,不会自动调用析构函数
B 当因为抛出了用户自定义的异常而离开作用域,不会自动调用析构函数
C 当因为抛出了runtime_error 而离开作用域,不会自动调用析构函数
D 只要抛出的异常被任何层次的调用者捕获,析构总是会执行。
2. RAII方案的主要用处是:__
A 正确初始化资源,避免浪费
B 确保对象离开作用域时,自动归还资源,以防止资源耗尽
C 主要用于文件打开后,确保关闭。但不能避免堆内存的泄漏。
D 主要用于配合异常机制使用,保证异常的正确捕获
答案
1=>D 2=>B
栈
栈是具有后进先出(LIFO)特征的线性结构。
栈的具体实现,可以是数组,动态数组,链表,块链等,视需求而定。
为了隔离实现层,我们可以设计出栈的接口。
这里还要考虑个问题:
我们并不知道用户会往栈中添加什么数据,怎样尽量兼容广泛的数据类型呢?
我们可以再做个接口,让用户的类型从它继承:
元素统一类型的接口
struct IObj{
virtual ~IObj(){}
virtual void show(ostream& os) const = 0;
};
ostream& operator<<(ostream& os, IObj* p)
{
p->show(os);
return os;
}
栈的接口
struct IStack{
virtual ~IStack() {}
virtual IStack& push(IObj*) = 0;
virtual IObj* pop() = 0;
virtual bool empty() = 0;
};
这个接口与实现方式无关。
它指出:
栈必须能支持 pop, push 操作,能判断是否为空栈。
我们用最简单的数组来实现栈:
class MyStack:public IStack{
public:
MyStack() { n = 0; }
virtual MyStack& push(IObj* x) override{
if(n>=100) throw "stack overflow";
data[n++] = x;
return *this;
}
virtual IObj* pop() override{
if(n==0) throw "empty pop!";
return data[--n];
}
virtual bool empty() override{
return n == 0;
}
private:
IObj* data[100];
int n;
};
然后,定义一个用户自己的具体元素类型。
以point为例:
struct Point: public IObj{
Point():Point(0,0){}
Point(int x, int y){this->x=x; this->y=y;}
virtual void show(ostream& os) const override{
os << "(" << x << "," << y << ")";
}
int x;
int y;
};
下面是使用 MyStack的方式。
注意:
元素的内存需要使用者来管理,MyStack 类内存储的是指针,它没有管理元素内存的责任。当然,这只是一种设计。我们也可以设计为:让MyStack在析构的时候,负责释放指针指向的空间。
// 面向接口编程,不了解底层实现方式
void clear(IStack& s)
{
while(!s.empty()){
cout << s.pop() << endl;
}
}
int main()
{
MyStack a;
Point p1 = Point(1,1);
Point* p2 = new Point(2,2);
Point* p3 = new Point(); // = Point(0,0)
a.push(&p1).push(p2).push(p3);
clear(a);
delete p2;
delete p3;
return 0;
}
实时效果反馈
1. 关于栈, 说法正确的是:__
A 栈中的元素必须是顺序存储,不能是链式存储
B 栈中的元素必须为指针类型
C 弹栈时,如果栈为空,返回NULL指针
D 栈是逻辑上的接口,它与具体的实现方式无关
2. 接口为什么要包含虚的析构函数?__
A 如果不这样,接口就会有可能实例化。
B 使用泛化的接口工作时,当接口对象离开作用域,会调用最恰当的析构函数
C 阻止子类重写析构函数
D 为了与虚的构造函数对应
答案
1=>D 2=>B
栈的链式实现
栈只是接口,并没有规定实现方式。
当用数组实现时,有个最大的缺点:栈的最大容量被固定死了。
如果用链表实现,就可以无限扩张了(还是要受限于物理内存)
设计
其它部分基本不动,只是对MyStack的实现重新设计:
这里需要一个 Node 对象,但这个对象并不需要外界知晓,可以作为private内部类
工作中的图景如下:
LinkStack中只有一个head,
它平常指向栈顶的 Node 节点,Node间链接,data 指向用户的数据
栈只负责维护自己的动态 Node 内存,不负责用户数据的内存管理。
实现代码
class LinkStack:public IStack{
public:
LinkStack() { head = NULL; }
virtual LinkStack& push(IObj* x) override{
head = new Node{x, head};
return *this;
}
virtual IObj* pop() override{
if(head==NULL) throw "empty pop!";
Node* t = head;
IObj* rt = t->data;
head = head->next;
delete t;
return rt;
}
virtual bool empty() override{
return head==NULL;
}
private:
struct Node{
IObj* data;
Node* next;
};
private:
Node* head;
};
struct Point: public IObj{
Point():Point(0,0){}
Point(int x, int y){this->x=x; this->y=y;}
virtual void show(ostream& os) const override{
os << "(" << x << "," << y << ")";
}
int x;
int y;
};
实时效果反馈
1. 栈的链表实现,相比于数组实现,说法正确的是:__
A 存储元素的数量几乎没有限制
B 运算速度更快,更灵活
C 不会抛出异常来
D 能自动释放堆空间上分配的用户数据
2. 为什么对同一接口,经常提供多个版本的具体实现,最可能的原因是:__
A 不同的场合有不同要求或约束条件,一个实现很难满足所有的应用场景
B 随着时间的推移,人们发现原来的实现有bug,提出了更好的方案
C 不同的公司因为版权问题,不得不给出自己实现版本
D 原来的程序员离职,源码看不懂,还不如重写一个实现
答案
1=>A 2=>A
栈的块链实现
栈的链表实现解决了元素个数限制的问题,
但,它在效率上有很大的牺牲。如何能更高效呢?
可以把链表和数组的优点结合起来使用,这就是块链。
设计
大块的数组间用单链表链接。
这样不会在每次 push的时候都去 new 新块。一个块满员后,才new 新块。
同理,一个块空时,把该块删除。
实现
class BlockStack:public IStack{
public:
BlockStack() { head = NULL; }
~BlockStack() { while(!empty()) delete pop(); }
virtual BlockStack& push(IObj* x) override{
if(head==NULL || head->n==BS) {
Node* t = new Node;
t->next = head;
head = t;
}
head->data[head->n++] = x;
return *this;
}
virtual IObj* pop() override{
if(head==NULL) throw "empty pop!";
IObj* rt = head->data[--head->n];
if(head->n==0){
Node* t = head;
head = t->next;
delete t;
}
return rt;
}
virtual bool empty() override{
return head==NULL;
}
private:
static const int BS=5; //这是外部类的 private, 内部类可用
struct Node{
Node(){ n=0; next=NULL; }
IObj* data[BS];
int n;
Node* next;
};
private:
Node* head;
};
实时效果反馈
1. 关于块链, 说法正确的是:__
A 块链就是动态数组
B 块链是分块的数组,分块的数组间通过链表连接
C 块链的操作效率高于数组
D 块链比单链表更节约空间
2. 关于内部类,说法正确的是:__
A 内部类可以访问外部类的私有成员
B 外部类可以访问内部类的私有成员
C 创建外部类的实例,会隐含创建内部类实例
D 内部类实例必须依赖外部类实例存在,不能单独存在
答案
1=>B 2=>A
括号匹配问题
问题
给定一个串,中间可能出现各种括号 (
, )
, [
,]
,{
,}
写一个函数,判断串中的括号是否匹配。
例如: ..(.[.]..(.).). {..}...
为匹配
....(...[..)..]...{..}...
为失配
....(..(...[...]..).....
也为失配
再一个失配的例子:.....)..[.{.}..[..]..].....
分析
这个问题与迷宫冒险类似。
比如一个迷宫,有很多带唯一标号的门。如果来时,我们走的门为:1,2,3
则,出去时,应该走的门是:3,2,1。
这就是:沿着原路返回的原则,就是:FILO的原则。
左括号看作一个门的入口,右括号看作一个门的出口。
左括号压栈,右括号弹栈。弹栈时必须与压栈时的括号配对儿。
实现
class Stack{
public:
Stack() { n = 0; }
void push(char x) { data[n++] = x; }
char pop() {
if(empty()) throw -1;
return data[--n];
}
bool empty() { return n==0; }
private:
char data[100];
int n;
};
bool good(const char* s)
{
Stack a;
try{
while(*s){
if(*s=='(') a.push(')');
if(*s=='[') a.push(']');
if(*s=='{') a.push('}');
if(*s==')' || *s==']' || *s=='}'){
if(a.pop() != *s) return false;
}
s++;
}
}
catch(int e){
return false;
}
return a.empty();
}
实时效果反馈
1. 关于FILO设计的用途, 说法正确的是:__
A 为了重复使用,节约内存
B 为了实现“沿着原路返回去”
C 为了使后来的元素有优先权
D 为了避免栈溢出
2. 关于catch语句,说法正确的是:__
A catch 只能抓住对象类型,不能抓住原生类型。
B catch 只能抓住指针类型,不能抓住对象类型。
C catch 只能抓住引用类型,不能抓住指针类型。
D catch 可以抓住任何 throw 的类型。
答案
1=>B 2=>D
循环队列
队列是一种抽象,可以实现为接口。
与栈一样,队列也是一种线性结构。
栈的特征是:后进先出。并且,只能在它的一个端头进行操作。
队列的特征是:先进先出。
并且,只能在它的两个端头进行操作。一个端头入队,一个端头出队。
用数组实现队列
最简单常见的场景,可以用数组来实现队列。
数组的大小是固定的,决定了这个队列的最大容量。
容易想到的方案:
这里需要解决的问题是:如何充分利用数组。
因为,front指针走过的位置荒废可惜了,如何重复利用?
可以把数组看成一个环状的缓冲区。
解决方案
class MyQue{
public:
MyQue(){ rear=0; front=0; }
MyQue& enque(int x){
if((rear + 1) % N == front) throw -1;
buf[rear] = x;
rear = (rear + 1) % N;
return *this;
}
int deque(){
if(empty()) throw -2;
int rt = buf[front];
front = (front + 1) % N;
return rt;
}
bool empty(){ return front==rear; }
private:
static const int N = 5;
int buf[N];
int rear;
int front;
};
测试一下:
MyQue a;
a.enque(1).enque(2).enque(3).enque(4);
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
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 | 逆向迭代器 |
构造方式
string s1; // 空串
string s2("1234567"); // 从 c-string 拷贝构造
string s3(s2); // 拷贝构造
string s4(s2,3); // s2 中从下标3开始
string s5(s2,3,2); // s2 中从下标3开始,2个字符
string s6(4,'x'); // 4 个 'x'
string s7(s2.end()-3,s2.end()); // 迭代器区间,最后3个char
输入,输出
输出简单。
输入注意:cin >> s, 遇到空格即认为输入结束,需要输入含有空格的串,可以用 getline
string s;
//cin >> s;
getline(cin, s);
cout << s << endl;
查找
string a = "12345671234567";
cout << a.find("56") << endl;
cout << a.find("58") << endl; // 返回 string::npos
// static const size_t npos = -1;
cout << a.find("56",7) << endl; // 从 7 位置开始往后找
注意,判断返回值是否查找成功要与 string::npos比较,不要写-1
find_first_of find_first_not_of … 任意一个字符匹配
遍历
string s = "abcdefg";
string::iterator i = s.begin();
while(i!=s.end()){
cout << *i << endl;
i++;
}
c++11 支持类型自动推断,可以用 auto 代替 string::iterator
string s = "abcdefg";
for(auto i=s.rbegin(); i!=s.rend(); i++){
cout << *i << endl;
}
实时效果反馈
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)
一般的开发实践中,我们首先最关心的问题是:
string 与 char* 类型如何完美互操作,相互转换。
string 比 char* 的主要优势在于:自动管理动态内存,不容易产生:
- 因忘记释放堆内存而内存泄漏
- 因分配空间不足,而产生使用越界
比如,传统上,返回串是件很尴尬的事情。
一般,两种设计:
char* f(...) // f函数动态申请,使用f的客户很容易忘记是释放
void f(char* buf, ...) // 客户方提供内容,f写入,容易超安全范围使用
如果返回 string 对象就没有这些问题。
string 如何转为 char*
len有两个作用:
- 不想全部拷贝,拷贝源串的一部分
- 做个最大限定,防止冲出去,破坏其它内存对象
string s = "12345";
char buf[100];
buf[s.copy(buf,99)] = '\0';
cout << buf << endl;
如果,只是读取,并不改写,不必要复制一份内存。
string s = "123456";
const char* s1 = s.c_str();
const char* s2 = s.data(); // c++99不保证有'\0'结束
cout << s1 << endl;
cout << s1 << endl;
整数与string的互转
- 可以使用 cstdlib(stdlib.h) 中的
aoti
string s = "123";
int a = atoi(s.c_str());
cout << a << endl;
- 也可以使用更一般的方案,对任何对象转换,更灵活有弹性
需要:#include <sstream>
string s = "123";
stringstream ss; // 操作类似于 cin, cout,只是针对串
int a;
ss << s;
ss >> a;
cout << a << endl;
- c++11 提供了更实用的解决方案
string s1 = "1234";
string s2 = "ff";
string s3 = "1010";
string s4 = "0x7f";
cout << stoi(s1) << endl;
cout << stoi(s2,NULL,16) << endl;
cout << stoi(s3,NULL,2) << endl;
cout << stoi(s4,NULL,0) << endl;
类似地,可以实现浮点数的转换:
- 整数转为string, char*
可以传统的 itoa
char buf[100];
itoa(255,buf,16);
cout << buf << endl;
传统的 sprintf
char buf[100];
sprintf(buf, "%04x", 255);
cout << buf << endl; // 00ff
流式的stringstream
stringstream ss;
string s;
ss << hex << 255;
ss >> s;
cout << s << endl;
也可以达到 sprintf 的控制效果,详见cin,cout一节
c++11 提供的 to_string
string s = to_string(255);
cout << s << endl;
不能精确控制,但简单、方便。
实时效果反馈
1. s是string类型,下列哪个特性是c++11的标准:__
A atoi
B stoi
C s.c_str()
D s += “123”
答案
1=>B
std::string 应用示例
STL 的string 类提供了串的最基本的操作集合(最小集合)
我们可以扩展它,实现更丰富的功能
split 操作
一个或多个空格为分隔符,把一个串分割为多个部分。
string s = " abc 1234 xyz kkkk ";
int p1 = 0;
while(1){
int p2 = s.find(" ", p1);
if(p2 == string::npos) {
cout << s.substr(p1) << endl;
break;
}
string s1 = s.substr(p1, p2-p1);
if(!s1.empty()) cout << s1 << endl;
p1 = s.find_first_not_of(" ", p2);
if(p1 == string::npos) break;
}
这个方案,逻辑较多,很多细腻的控制,容易带入bug。
其实,
char buf[100] = " abcd xyz 1234 ";
char* p = strtok(buf, " ");
while(p){
cout << p << endl;
p = strtok(NULL, " ");
}
这个解决方案很值得借鉴。
一般的高级语言,都会返回一个数组类型,c++为了效率,没有这样做。
配合static保存上一次的结果,可以方便地进行对结果的遍历访问。
trim 操作
去掉串的首尾空格
string s = " abcd ";
s.erase(0, s.find_first_not_of(" "));
s.erase(s.find_last_not_of(" ")+1);
cout << s << endl;
cout << s.length() << endl;
需要仔细考虑找不到的情况,比如源串为空串。
string再封装
class mystr: public string{
public:
using string::string;
void trim(){
erase(0, find_first_not_of(" "));
erase(find_last_not_of(" ")+1);
}
};
注意: using 一句,这是c++11 引入的方便,否则,需要显式地进行构造函数透传。
因为,string的构造函数特别多样,透传是很头疼的事情。
使用方法:
mystr s(" abcd ");
s.trim();
cout << s << endl;
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
cin 标准输入流,默认指键盘,可以重定向。
cout 标准输出流,默认指显示终端,可以重定向。
一般要重载两个运算符:
<<
插入运算符,把对象序列化到流中。
>>
提取运算符,把流中的东西重建为对象。
惯例上,这两个运算符都返回流对象的引用,以便于连续书写。
string name;
int age;
cout << "please input name and age: ";
cin >> name >> age;
cout << name << ":" << age << endl;
注意,每个提取项默认都是空白字符分割(空格,tab, 回车)
如果希望串中含有分割符,就需要getline
cin 有几个bool函数判断当前内部状态:
bool good(); // true 表示一切正常
bool eof(); // true 表示达到了流的末尾,比如文件结束
bool fail(); // true 表示遇到非法数据,可以恢复
bool bad(); // true 发生了物理上的致命错误,流无法继续使用
示例,输入一个整型数,直到正确为止:
int a;
while(1){
cin >> a;
if(cin.good()) break;
cout << "err data, input again!" << endl;
cin.clear(); // 清空状态标志
while(cin.get()!='\n'); // 清空缓冲区
}
cout << "a: " << a << endl;
cout的格式控制
cout 也可以像 printf 那样仔细控制输出格式。
printf("%010.3f", 3.1415926); // 000003.142
可以看到精细的控制点:
- 总宽度
- 小数精度
- 左右对齐方式
- 不足位置补的字符(默认是空格,可以改为0,#等)
cout 也有这个控制能力,需要头文件:
#include <iomanip>
double a = 3.1415926;
cout << a << endl;
cout << fixed // 强制以小数方式显示
<< setprecision(3) // 设置小数显示精度
<< a << endl;
cout << setw(10) // 显示宽度
<< left
<< setfill('#') // 填充字符
<< a << endl;
流是统一的概念
我们介绍的流操作可以适用于:
cin, cout
文件I/O流
stringstream 等
这极大地简化了概念和互操作性。
istream: 是用于输入的流类,cin就是该类的对象。 ostream: 是用于输出的流类,cout就是该类的对象。 ifstream: 是用于从文件读取数据的类。 ofstream: 是用与向文件写入数据的类。
stringstream: 用于向串流对象中写入或读取。
比如,文件的读入,与从cin读入没有什么区别:
需要#include <fstream>
ifstream in("d:\\1.txt");
if(!in) {
cout << "no such file!" << endl;
return -1;
};
char buf[100];
while(in.getline(buf, 80)){
cout << buf << endl;
}
实时效果反馈
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
标准输入输出的重定向
cin, cout 与 文件流、字符串流一样,都是广义上的流,当面向高层概念编程时,底层的类型之间可以偷偷换一下。
所以,标准的输入输出可以重定向。
举例来说:原来从键盘输入,可以改为从某个准备好的字符串流输入。
原来是输出到屏幕,可以重定向到某个固定的文件里。
场景
最常见的应用场景:在线的 OJ 网站,可以对我们提交的源代码测评。
用很多测试用例灌入程序,看它的输出结果。
这就需要把输入,输出都定向为文件。
可以在 leetcode 刷题上体会下。
重定向法一:
c 语言中原来常用的做法
把屏幕重定向到文件:
freopen("d:\\out.txt", "w", stdout);
cout << "haha" << endl;
cout << 123 << endl;
把键盘重定向到文件:
freopen("d:\\in.txt", "r", stdin);
string s;
while(getline(cin, s)){
cout << s << endl;
}
重定向法二:
如果希望使用更加 c++ 的风格,可以用 rdbuf() 函数
rdbuf 定义在
有两种重载的格式:
streambuf * rdbuf() const; // 返回当前的缓冲区
streambuf * rdbuf(streambuf * sb); // 设置新的缓冲区
下面示例,把 cin 从键盘改为从 stringstream 中输入。
需要include
stringstream ss;
ss << "1 2 3" << "\n";
ss << "4 5";
streambuf* old = cin.rdbuf(ss.rdbuf());
int a = 0;
int t;
while(cin >> t) a += t;
cout << a << endl;
cin.rdbuf(old);
模仿 cin, 也可以 对 cout 这样重定向。
重定向法三:
与语言无关,可以在控制台上用命令行来重定向:
这样,本来输出到屏幕的内容就重定向到文件中了。
类似地,也可以把从键盘读入的内容,转而从某个准备好的文件读入:
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