✍🏻 C++ 高级程序设计
期末题型:
- 简述题 4*5,基本概念的理解
- 程序片段,修改错误 4*10,两道是:给代码,指出错误;另两份:给代码,指出输出
- 手搓代码,编程题 2*20
概念
C++ 与 C 的区别:在 C 的基础上,C++ 增加了一些新的语言机制,例如:更好地支持过程式编程以提高与类型相关的安全性(基本扩充)、支持面向对象编程、支持泛型编程、支持函数式编程……
指针和引用的区别:
指针 引用 指针是一个变量,它存储另一个变量的内存地址,
可以不在定义时初始化。引用是一个别名,它必须在定义时初始化。 指针可以在任何时候修改它所指向的对象。 引用一旦初始化,就不能再引用其他对象。 指针可以被赋值为 nullptr,表示它不指向任何对象。引用不能是空的,必须引用一个有效的对象。 指针支持算术运算,如加减运算。 引用不支持算术运算。 通过解引用运算符 *来访问指针所指向的对象。通过引用名直接访问引用的对象,无需解引用运算符。 函数的重载:在相同的作用域中,用同一个名字定义多个不同的函数(具有不同的参数)。
面向对象程序设计的特点:
- 程序由若干对象组成,每个对象是由一些数据以及对这些数据所能实施的操作所构成的封装体;
- 对数据的操作是通过向包含数据的对象发送消息(调用对象对外接口中的操作)来实现的,体现了数据抽象;
- 对象的特征由相应的类来描述,这些特征可以从其它的类继承 / 获得。
- 通过类和对象的概念来实现抽象、封装、继承和多态等特性
抽象:抽象是指从具体的事物中提取出其共有的、关键的特征,而忽略掉不必要的细节,目的是简化复杂系统,使得我们可以专注于系统的高层次设计,而不必关心底层实现细节。
封装:封装是指把该程序实体内部的具体实现细节对使用者隐藏起来,只对外提供一个接口。
多态:多态是指同一个操作在不同对象上可以有不同的实现。
继承:继承是从已有类创建新类的机制。在定义一个新的类时,先把已有的一个或多个类的功能全部包含进来,然后再在新的类中给出新功能的定义或对已有类的某些功能进行重新定义。
绑定:确定对多态元素的某个使用是多态元素的哪一种形式。
模块:从物理上对程序中定义的实体进行分组,是可以单独编写和编译的程序单位。
范型:范型是一种编程范式,它允许编写与特定数据类型无关的代码,从而实现代码的重用性和灵活性。C++ 中的范型编程主要通过模板来实现。
代码复用:指开发新软件时,把现有软件的一些代码拿过来用,其好处是:提高开发效率、保证软件质量。
为什么模板是代码复用?因为它允许编写通用的代码,通过参数化类型,使同一段代码可以处理不同的数据类型,从而避免重复编写相似的代码,提高代码的可维护性和可扩展性。
MFC 提供的“文档-视”结构为程序设计带来什么好处?通过将数据管理(文档)和用户界面显示(视图)分离,支持多视图显示同一数据,确保数据一致性,提高代码复用性和可维护性,简化复杂应用程序的开发。
迭代器:属于一种智能指针,它们指向容器中的元素,用于对容器中的元素进行访问和遍历。
应用框架:是一个可重用的设计和实现的集合,它提供了一个通用的结构和一组预定义的类和函数,用于构建软件应用程序。
函数式程序设计:一种编程范式,它强调使用纯函数和不可变数据来构建程序。函数式编程的核心思想是将计算视为函数的应用,避免使用可变状态和副作用。
事件驱动编程:一种编程范式,其中程序的执行流由事件的发生来驱动。事件可以是用户的操作(如鼠标点击、键盘输入)、传感器的输入、消息的到达等。事件驱动编程广泛应用于图形用户界面(GUI)应用程序、网络服务器、嵌入式系统等。
面向对象的事件驱动编程:结合了面向对象编程和事件驱动编程的优点,通过对象和事件的交互来实现程序的功能。
内联函数
定义函数时,在函数返回类型之前加上一个关键词 inline。作用是建议编译程序把该函数的函数体展开到调用点,函数调用时直接执行函数体,提高对小函数的调用效率。
动态变量
动态变量是指在程序运行中,由程序根据需要额外创建的变量,主要用于可变大小的链表、树、图等数据结构。动态变量没有名字,需要通过指向它的指针变量来访问。比如 int *p1 = new int。用 delete 撤销。
引用类型
用来给一个变量取一个别名,通过该别名可以访问原来的变量。引用主要用于函数的参数类型,实现指针类型参数的效果,但比指针类型抽象和安全。
函数名重载
在相同的作用域中,用同一个名字定义多个不同的函数(具有不同的参数)。确定一个对重载函数的调用对应着哪一个重载函数定义的过程称为绑定,一般是在编译时刻由编译程序根据实参与形参的匹配情况来决定。
λ 表达式
λ 表达式是一种匿名函数机制,利用它可以把函数的定义和使用二者合而为一。 格式:[<环境变量使用说明>] (<形式参数>) -> <返回值类型> {<函数体>}。其中,“环境变量使用说明”:
- 空:不能使用外层作用域中的自动变量。
&:按引用方式使用外层作用域中的自动变量(可以改变值)。=:按值方式使用外层作用域中的自动变量(不能改变值)。
编译预处理命令
编译预处理命令不是 C++ 程序所要完成的功能,而是用于对编译过程给出指导,其功能由编译预处理系统在编译时候来完成。主要有:文件包含命令 #include、宏定义命令 #define、条件编译命令 #ifdef #endif(避免包含头文件带来的重复定义或声明 & 基于多环境的程序编制)。
抽象与封装
- 抽象:指该程序实体外部可观察到的行为,使用者不考虑该程序实体的内部是如何实现的(复杂度控制);
- 封装:指把该程序实体内部的具体实现细节对使用者隐藏起来,只对外提供一个接口(信息保护)。
过程抽象与封装
- 过程抽象:用一个名字来代表一段完成一定功能的程序代码,代码的使用者只需要知道代码的名字以及相应的功能,而不需要知道对应的程序代码是如何实现的。
- 过程封装:把命名代码的具体实现隐藏起来(对使用者不可见 / 不可直接访问),使用者只能通过代码名字来使用相应的代码。命名代码所需要的数据是通过参数来获得,计算结果通过返回值机制返回。
数据抽象与封装
- 数据抽象:只描述对数据能实施哪些操作以及这些操作之间的关系,数据的使用者不需要知道数据的具体表示形式(数组或链表等)。
- 数据封装:把数据及其操作作为一个整体(封装体)来进行实现,其中,数据的具体表示被隐藏起来(使用者不可见 / 不可直接访问),对数据的访问(使用)只能通过封装体对外接口中提供的操作来完成。
- 与过程抽象与封装相比,数据抽象与封装能够实现更好的数据保护。
面向对象程序设计
- 程序由若干对象组成,每个对象是由一些数据以及对这些数据所能实施的操作所构成的封装体;
- 对数据的操作是通过向包含数据的对象发送消息(调用对象对外接口中的操作)来实现,体现了数据抽象;
- 对象的特征(包含哪些数据与操作)由相应的类来描述;
- 一个类所描述的对象特征可以从其它的类继承/获得(如果没有“继承”,则称为:基于对象的程序设计);
- 在面向/基于对象的程序设计中,对象 / 类体现了数据抽象与封装。
拷贝构造函数
若一个构造函数的参数类型为本类的引用,则称它为拷贝构造函数。
1
2
3
4
5
6
class MyClass
{
public:
MyClass(); // 默认构造函数
MyClass(const MyClass &a); // 拷贝构造函数
};
在三种情况下,会调用类的拷贝构造函数:
- 创建对象时显式指出。
1 2
MyClass a1; MyClass a2(a1); // 创建对象 a2,用对象 a1 初始化对象 a2
- 把对象作为值参数传给函数时。
1 2 3
void f(MyClass x) {...}; MyClass a; f(a); // 创建形参对象 x,用对象 a 对 x 进行初始化
- 把对象作为函数的返回值时。
1 2 3 4 5
MyClass f() { MyClass a; return a; // 创建返回值对象,用对象 a 对返回值对象,进行初始化 }
在程序中,如果没有为某个类提供拷贝构造函数,则编译器将会为其生成一个隐式拷贝构造函数。一般情况下,该隐式拷贝构造函数的行为足以满足要求,但在一些特殊情况下,必须要自定义拷贝构造函数,否则,将会产生设计者未意识到的严重的程序错误。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class String
{
int len;
char *str;
public:
String(const char *s)
{
len = strlen(s);
str = new char[len + 1]; // 没有自定义拷贝构造函数
strcpy(str, s);
}
~String()
{
delete[] str;
len = 0;
str = NULL;
}
};
String s1("abcd");
String s2(s1); // 隐式拷贝构造函数将使 s1 和 s2 的成员指针 str 指向同一块内存区域
系统提供的隐式拷贝构造函数实施的是浅拷贝(shallow copy),只拷贝数据成员本身的值。它带来的问题是:
- 当一个对象被析构时,它会释放这块内存,其他对象的指针会变成空指针;
- 如果一个对象修改了指针指向的内容,其他对象也会受到影响。
为了解决上面的问题,可以在类 String 中显式定义一个拷贝构造函数来实现深拷贝(deep copy)。
1
2
3
4
5
6
String::String(const String &s) // 自定义拷贝构造函数
{
len = s.len;
str = new char[len + 1]; // 深拷贝,为新对象分配新的内存
strcpy(str, s.str);
}
注意:自定义的拷贝构造函数不会自动调用成员对象类的拷贝构造函数,需要在成员初始化表中显式指出。
常成员函数
为了防止在一个获取对象状态的成员函数中无意中修改对象数据成员的值,可以把它说明成常成员函数 const。常成员函数无法修改数据成员的值,否则编译器将会报错。
1
2
3
4
5
6
7
8
class Date
{
public:
void set(int y, int m, int d);
int get_day() const; // 常成员函数
int get_month() const; // 常成员函数
int get_year() const; // 常成员函数
};
另外,对常量对象只能调用类中的常成员函数,即不能修改常量对象的值。
1
2
3
4
5
6
7
void f(const Date &d) // d 引用的是个常量对象!
{
d.get_day(); // OK
d.get_month(); // OK
d.get_year(); // OK
d.set(2025, 1, 1); // Error
}
静态数据成员 / 静态成员函数
采用静态数据成员 static 可以更好地实现同一个类的不同对象之间的数据共享。(为何不使用全局变量?因为全局变量共享的数据与对象之间缺乏显式的联系,且数据缺乏保护。)类的静态数据成员对该类所有对象只有一个拷贝。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyClass
{
int y;
static int x; // 静态数据成员声明
void visitX()
{
y = x;
x++; // 访问共享的 x
}
};
int MyClass::x = 0; // 静态数据成员定义及初始化
MyClass a, b;
a.visitX();
b.visitX();
// 上述操作对同一个 x 进行
x++; // Error,不通过 MyClass 类对象不能访问 x
成员函数也可以定义成静态成员函数。静态成员函数只能访问类的静态成员,且静态成员函数没有隐藏的 this 参数,且静态成员函数不能定义成虚函数。
友元
类中定义的私有数据成员不能在外界直接访问,在有些情况下,这种对数据访问的方式效率不高。
为此,可以指定某些与一个类密切相关、但又不适合作为该类成员的程序实体能直接访问该类的私有数据成员,这些程序实体称为该类的友元。友元需要在类中用 friend 显式指出,它们可以是全局函数、其它类的所有成员函数、其它类的某个成员函数。友元是数据保护和数据访问效率之间的一种折衷方案。
1
2
3
4
5
6
7
8
class MyClass
{
friend void func(); // 全局函数 func 可访问 x
friend class A; // 类 A 的所有成员函数可访问 x
friend void B::f(); // 类 B 的成员函数 f 可访问 x
private:
int x;
};
基本操作符重载
C++ 允许对已有的操作符进行重载,可通过定义一个名为 operator# 的函数来实现。与函数名重载一样,操作符重载也是实现多态性的一种语言机制。例如,重载复数的 +:
1
2
3
4
5
6
7
8
9
10
11
class Complex
{
public:
Complex operator+(const Complex &x) const
{
return Complex(real + x.real, imag + x.imag);
}
};
Complex a(1.0, 2.0), b(3.0, 4.0), c;
c = a + b; // 按 a.operator + (b) 实现
双目操作符重载
作为成员函数重载:只需要提供一个参数,它对应第二个操作数,第一个操作数由隐藏的参数
this给出。例如,实现复数的“等于”和“不等于”操作:1 2 3 4 5 6 7 8 9 10 11 12 13
class Complex { double real, imag; public: bool operator==(const Complex &x) const { return (real == x.real) && (imag == x.imag); } bool operator!=(const Complex &x) const { return !(*this == x); } };
作为全局(友元)函数重载:需要提供两个参数,其中至少应该有一个是类、结构、枚举或它们的引用类型。例如,重载操作符
+,使其能够实现实数与复数的混合运算。1 2 3 4 5 6 7 8
Complex operator+(const Complex &c1, const Complex &c2) { return Complex(c1.real + c2.real, c1.imag + c2.imag); } Complex operator+(const Complex &c, double d) { return Complex(c.real + d, c.imag); }
单目操作符重载
作为成员函数重载:不需要提供参数,操作数由隐藏的参数
this指出)。例如,实现复数的取负操作。1 2 3 4 5 6 7 8
class Complex { public: Complex operator-() const { return Complex(-real, -imag); } };
作为全局(友元)函数重载:只需要提供一个参数,其类型必须是类、结构、枚举或它们的引用类型。例:实现判断复数是否为“零”的操作。
1 2 3 4
bool operator!(const Complex &c) { return (c.real == 0.0) && (c.imag == 0.0); }
操作符 ++ 和 -- 的重载:
单目操作符 ++ 和 --:
它们只有一个操作数,并且该操作数为一个左值表达式。
它们是带副作用的操作符,得到一个计算结果的同时会改变操作数的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Counter
{
int value;
public:
Counter &operator++() // 前置的 ++ 重载函数
{
value++;
return *this;
}
const Counter operator++(int) // 后置的 ++ 重载函数
// 带有一个额外的 int 型参数,只是用于从形式上
// 把后置的重载函数与前置的重载函数区分开。
{
Counter temp = *this; // 保存原来的对象
++(*this); // 调用前置的 ++ 重载函数!
return temp; // 返回原来的对象
}
};
特殊操作符重载
C++ 特殊操作符:赋值操作符 =、数组元素访问操作符 []、函数调用操作符 ()、类成员访问操作符 ->(智能指针)、动态对象创建与撤销操作符 new 与 delete 、类型转换操作符、输入输出操作符 >> 与 <<。
赋值操作符 = 的重载
C++ 编译程序会为每个类定义一个隐式的赋值操作,但隐式的赋值操作有时不能满足要求(浅 / 深拷贝的问题),需要自己定义赋值操作符重载函数。一般来讲,需要自定义拷贝构造函数的类通常也需要自定义赋值操作符重载函数。 例如,对字符串类的对象进行赋值操作(注意防止自身赋值):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class String
{
int len;
char *str;
public:
String &operator=(const String &s)
{
if (&s == this)
return *this; // 防止自身赋值:a = a
delete[] str; // 归还 str 原来指向的空间
str = new char[s.len + 1]; // 申请新的空间
strcpy(str, s.str); // 把用于赋值的字符串复制到新空间中
len = s.len;
return *this;
}
};
如果有成员对象,自定义的赋值操作符重载函数不会自动去调用成员对象类的赋值操作,需要在自定义的赋值操作符重载函数中显式指出。
数组元素访问操作符 [] 的重载
对于由具有线性关系的元素所构成的对象,可通过重载下标操作符 [] 来实现对其元素的访问。例如:
1
2
3
4
5
6
7
8
class String
{
int len;
char *str;
public:
char &operator[](int i) { return str[i]; }
char operator[](int i) const { return str[i]; } // 用于常量对象
};
函数调用操作符 () 的重载
在 C++ 中,把函数调用也作为一种操作符来看待,操作数为函数名及各个实参,结果为函数返回值。可以针对某个类重载函数调用操作符,使得相应类的对象可以当作函数来使用。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class MyClass
{
int value;
public:
MyClass(int i) { value = i; }
int getValue() { return value; }
int operator()(int x, int y) // 函数调用操作符 () 的重载函数
{
return x * y + value;
}
};
MyClass a(1); // a是个对象
cout << a.getValue() << endl; // 把 a 当对象来用
cout << 10 + a(10, 20) << endl; // 把 a 当函数来用!
// a(10,20) 等价于:a.operator()(10,20)
函数调用操作符重载主要用于具有函数性质的对象,该对象通常只有一个操作,可用函数调用操作符重载函数来表示该操作。
类成员访问操作符 -> 的重载
-> 为一个双目操作符,第一个操作数为一个指向类或结构的指针,第二个操作数为第一个操作数所指向的类或结构的成员。可以针对某个类重载 -> 操作符,这样就可以把该类的对象当指针来用,实现一种智能指针。智能指针的好处:通过智能指针去访问它指向的对象成员之前能做一些额外的事情(在操作符 -> 重载函数中实现)。例如,在程序执行的某个时刻获取某个对象被访问的次数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class A
{
int x, y;
public:
int z;
void f();
void g();
};
class PtrA // 智能指针类
{
A *p_a; // 指向 A 类对象的普通指针
int count; // 用于对 p_a 指向的对象进行访问计数
public:
PtrA(A *p) : p_a(p), count(0) {}
A *operator->() // 操作符 “->” 的重载函数,按单目操作符重载
{
count++; // 每次被访问先 + 1
return p_a;
}
};
void visitA(PtrA &p) // p 是个 PtrA 类对象
{
p->f(); // 通过 p-> 访问 a 的成员
p->g(); // 通过 p-> 访问 a 的成员
p->z; // 通过 p-> 访问 a 的成员
}
A a;
PtrA b = &a; // b 为一个智能指针,它指向了 a
b->f(); // 访问 a 的成员 f,等价于 b.operator->()->f();
visitA(b); // 把 b 传给 visitA
在 C++ 中,智能指针主要用于对动态对象的空间进行管理。C++ 标准库提供了一些智能指针类型,其中包括:
shared_ptr,一种引用计数智能指针,多个shared_ptr可以共享同一个对象。当最后一个shared_ptr被销毁时,对象才会被释放。(引用计数:记录有多少个指针引用了该对象。当引用计数变为零时,表示没有指针引用该对象,此时可以安全地释放对象的内存。)unique_ptr,是一种独占所有权的智能指针,确保同一时间只有一个unique_ptr拥有对象的所有权。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class A
{
int x;
public:
A(int i) { x = i; }
~A() { cout << "In A's destructor\n"; }
void getX() { cout << x << endl; }
};
shared_ptr<A> sp1(new A(1)); // 创建第一个动态对象 sp1,其引用计数为 1
sp1->getX(); // 调用 sp1 的成员函数 getX,输出 1
shared_ptr<A> sp2(new A(2)); // 创建第二个动态对象 sp2,其引用计数为 1
sp2->getX(); // 调用 sp2 的成员函数 getX,输出 2
sp1 = sp2; // sp1 共享 sp2 的对象,sp2 的引用计数加 1(变成 2)
// 原来的 sp1 对象的引用计数减 1(变成 0),原来的 sp1 对象自动消亡
sp2 = nullptr; // sp2 的引用计数减 1(变成 1)
sp1->getX(); // 调用 sp1 的成员函数 getX,输出 2
sp1 = nullptr; // sp1 的引用计数减 1(变成 0),sp1 对象自动消亡
unique_ptr<A> up3(new A(3)); // 创建第三个动态对象
unique_ptr<A> up4(new A(4)); // 创建第四个动态对象
up3 = up4; // Error,第四个对象被 p4 独占
up3 = nullptr; // 第三个对象消亡
up4 = nullptr; // 第四个对象消亡
操作符 new 与 delete 的重载
操作符 new 有两个功能:为动态对象分配空间、调用对象类的构造函数。
操作符 delete 也有两个功能:调用对象类的析构函数、释放动态对象的空间。
可以针对某个类重载操作符 new 和 delete,使得该类能以自己的方式来实现动态对象空间的分配和释放功能。(重载操作符 new 和 delete 时,重载的是它们的分配空间和释放空间的功能,不影响对构造函数和析构函数的调用。)
重载操作符
new:void *operator new(size_t size);- 操作符
new必须作为静态的成员函数来重载(可以不写static)。 - 返回类型必须为
void*。 - 参数
size表示对象所需空间的大小,其类型为size_t。
- 操作符
重载操作符
delete:void operator delete(void *p, size_t size);- 一般来说,如果对某个类重载了操作符
new,则相应地也要重载操作符delete。 - 操作符
delete必须作为静态的成员函数来重载(可以不写static)。
- 一般来说,如果对某个类重载了操作符
- 返回类型必须为
void。- 第一个参数类型为
void*,指向对象的内存空间。
- 第一个参数类型为
- 第二个参数可有可无,如果有,则必须是
size_t类型。
例如,把动态对象初始化为全 “0”:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class A
{
int x, y;
public:
void *operator new(size_t size)
{
void *p = malloc(size); // 申请堆空间
memset(p, 0, size); // 把申请到的堆空间初始化为全 “0”。
return p;
}
void operator delete(void *p)
{
free(p);
}
};
A *ptr=new A; //调用 new 重载函数,把 A 的大小传给 size
delete ptr; //调用 delete 重载函数,把 ptr 传给 p
自定义类型转换操作符
类中带一个参数的构造函数可以用作从其它类型到该类的转换。 例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
class Complex
{
double real, imag;
public:
Complex() { real = 0; imag = 0; }
Complex(double r) { real = r; imag = 0; } // 带一个参数的构造函数
Complex(double r, double i) { real = r; imag = i; }
friend Complex operator+(const Complex &x, const Complex &y);
};
Complex c(1, 2);
...(c + 1.7)... // 1.7 隐式转换成一个复数对象 Complex(1.7)
...(2.5 + c)... // 2.5 隐式转换成一个复数对象 Complex(2.5)
也可以定义从一个类到其它类型的转换。例如:
1
2
3
4
5
6
7
8
9
10
class A
{
int x,y;
public:
operator int(){ return x + y; } // 类型转换操作符 int 的重载函数
};
A a;
int i = 1;
...(i + a)... // 将调用类型转换操作符 int 的重载函数,把对象 a 隐式转换成 int 型数据
对于类型转换的歧义问题,可以用显式类型转换来解决,也可以通过修饰符 explicit 禁止隐式类型转换。
输入输出操作符 >> 与 << 的重载
标准库中重载的操作符 >> 和 << 只能对基本数据类型的数据进行输入 / 输出。可以针对自定义的类进一步重载它们,从而能用它们对某个类的对象进行输入 / 输出操作。 例如,用操作符 << 实现复数的输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Complex
{
friend ostream &operator<<(ostream &out, const Complex &c);
private:
double real, imag;
};
ostream &operator<<(ostream &out, const Complex &c)
{
out << c.real << '+' << c.imag << 'i';
return out;
}
Complex c1, c2;
cout << c1 << " " << c2 << endl;
继承
继承是指在定义一个新的类时,先把已有的一个或多个类(基类)的功能全部包含进来,然后再在新的类(派生类)中给出新功能的定义或对已有类的某些功能进行重新定义。除了拥有新定义的成员外,派生类拥有基类的所有成员(基类的构造函数 / 析构函数 / 赋值操作除外)。在 C++ 中,提供类成员访问控制 protected,用它说明的成员不能通过对象使用,但可以在派生类中使用。
单继承定义格式:class <派生类名>: [<继承方式>] <基类名>。其中,<继承方式> 用于指出从基类继承来的成员在派生类中对外的访问控制。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class BaseClass // 基类
{
int x, y;
public:
void f();
void g();
};
class DerivedClass : public BaseClass // 派生类
{
int z; // 新成员
public:
void h(); // 新成员
};
若派生类中定义了与基类同名的成员,则基类的成员名在派生类的作用域内不直接可见(被隐藏),在派生类中访问基类同名的成员时要用基类名受限。例如,下面 DerivedClass 类中的 f 隐藏了 BaseClass 类中的 f:
1
2
3
4
5
6
7
8
9
10
11
class DerivedClass : public BaseClass
{
int z;
public:
void f(); // 隐藏了 BaseClass 的 f!
void h()
{
f(); // DerivedClass 中的 f
BaseClass::f(); // BaseClass 中的 f
}
};
即使派生类中定义了与基类同名但参数不同的成员函数,基类的同名函数在派生类的作用域中也是不直接可见的,仍然需要用基类名受限方式来使用之。注意:DerivedClass 类中的 f 与 BaseClass 类中的 f 不属于函数名重载,因为它们属于不同的作用域!
public 继承与子类型:对用类型 T 表达的所有程序,当用类型 S 去替换程序中的所有的类型 T 时,程序的功能不变,则称类型 S 是类型 T 的子类型。(即:S 能实现 T 的所有功能,需要 T 类型的地方可以用 S 类型去替代。)在 public 继承中,由于派生类对外接口中包含了基类对外接口中的所有操作,因此,以 public 方式继承的派生类可看作是基类的子类型,在需要基类对象的地方可以用派生类对象去替代。
派生类对象的初始化:从基类继承的数据成员由基类的构造函数初始化,派生类新的数据成员由派生类的构造函数初始化。初始化时,调用构造函数的顺序:基类 → 成员对象类 → 派生类自身。消亡时调用析构函数顺序反之。
虚函数
虚函数是指加了关键词 virtual 的成员函数。虚函数有两个作用:
- 指定消息采用动态绑定;
- 指出基类中可以被派生类重定义的成员函数。
消息的动态绑定
在 public 继承中,一条可以发送到基类对象的消息(调用对象对外接口中的操作),也可以发送到派生类对象,从而会得到不同的处理。如果在基类和派生类中都给出了对这条消息的处理函数,那么这条消息存在特殊的多态性:由于在 public 继承中,基类的指针或引用可以指向或引用基类对象,也可以指向或引用派生类对象,那么,当通过基类的指针或引用向它指向或引用的对象发送消息时,它会调用哪一个消息处理函数呢?
C++ 默认采用的是静态绑定,即在编译时刻根据对象的类型来决定采用哪一个消息处理函数。然而,通常需要在程序运行时,根据对象的实际类型确定调用哪个方法,即采用动态绑定。在 C++ 中,需要在基类中用虚函数来指出动态绑定。
例如,下面 BaseClass 类中的 message 是个虚函数,对该函数的调用将采用动态绑定:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class BaseClass
{
public:
virtual void message() // 虚函数
{
cout << "Message from BaseClass" << endl;
}
};
class DerivedClass : public BaseClass
{
public:
void message()
{
cout << "Message from DerivedClass" << endl;
}
};
void showMessage(BaseClass &x) // 发送消息
{
x.message(); // 调用 BaseClass::message 或 DerivedClass::message,
// 具体哪个由实际引用(或指向)的对象来决定。
// 注:如果 message 不是虚函数,则直接调用 BaseClass::message。
}
BaseClass a;
DerivedClass b;
showMessage(a); // 调用 BaseClass::message
showMessage(b); // 调用 DerivedClass::message,而非默认的 BaseClass::message
注意:
- 只有通过基类的指针或引用访问基类的虚函数时才进行动态绑定。
- 只要在基类中说明了虚函数,在派生类、派生类的派生类……中,与基类同型构的成员函数都是虚函数,
virtual可以不写。 - 构造函数不能是虚函数,析构函数可以(往往)是虚函数。
消息动态绑定的各种情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class A
{
public:
A() { f(); }
~A() { f(); } // 不是虚析构函数!
virtual void f();
void g();
void h() { f(); g(); }
};
class B: public A
{
public:
B();
~B();
void f();
void g();
};
A a; // 调用 A::A() 和 A::f
a.f(); // 调用 A::f
a.g(); // 调用 A::g
a.h(); // 调用 A::h、A::f 和 A::g
// a 消亡时会调用 A::~A() 和 A::f
B b; // 调用 B::B()、A::A() 和 A::f
b.f(); // 调用 B::f
b.g(); // 调用 B::g
b.h(); // 调用 A::h、B::f 和 A::g
// b 消亡时会调用 B::~B()、A::~A() 和 A::f
A *p; // p 是 A 类(基类)指针
p = &a; // p 指向 A 类对象
p->f(); // 调用 A::f
p->g(); // 调用 A::g
p->h(); // 调用 A::h, A::f 和 A::g
p = &b; // p 指向 B 类对象
p->f(); // 调用 B::f
p->A::f(); // 调用 A::f,类名受限采用静态绑定
p->g(); // 调用 A::g,非虚函数采用静态绑定
p->h(); // 调用 A::h, B::f 和 A::g
p = new B; // 调用 B::B(), A::A() 和 A::f
delete p; // 只调用 A::~A() 和 A::f,没调用 B::~B(),为什么?
// 没有把 A 的析构函数定义为虚函数!!!
在派生类重定义基类成员函数
重定义是指在派生类中重新定义基类中的虚函数(区分:重载是指在相同的作用域中,用同一个名字定义多个不同的函数)。为防止重定义时错写成一个新定义的函数,可给重定义函数加上 override,编译器将检查基类中是否存在同型构的虚函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class BaseClass
{
int x, y;
public:
virtual void f(); // 虚函数
};
class DerivedClass : public BaseClass
{
int z;
public:
void f(int) override; // 重定义 A 中的 f
void f(double); // 新定义的成员函数
void g();
};
纯虚函数与抽象类
纯虚函数是没给出实现的虚函数,函数体用 “= 0” 表示,例如:
1
2
3
4
5
class A
{
public:
virtual int f() = 0; // 纯虚函数
};
纯虚函数需要在派生类中给出实现。包含纯虚函数的类称为抽象类。抽象类不能用于创建对象。它的作用是:
- 为派生类提供一个基本框架;
- 为同一个功能的不同实现提供一个抽象描述(接口)。
为派生类提供基本框架
例如,为 Rectangle,Circle,Line 等各种图形对象设计一个抽象基类 Figure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Figure // 抽象基类
{
public:
virtual void draw() const = 0; // 派生类共有的功能
virtual void input_data() = 0; // 派生类共有的功能
};
class Rectangle : public Figure
{
double left, top, right, bottom;
public:
void draw() const
{
... // 画矩形
}
void input_data()
{
cout << "请输入坐标 (x1,y1,x2,y2) :";
cin >> left >> top >> right >> bottom;
}
double area() const // 派生类特有的功能
{
return (bottom - top) * (right - left);
}
};
提供接口
由于在 C++ 中使用某个类时必须要见到该类的定义,因此,使用者能够见到该类的一些实现细节(如:非 public 成员),这样会使得类的抽象作用大打折扣。同时,有手段绕过类的访问控制而使用类的非 public 成员,类的封装作用可能被破坏,带来安全问题。
为解决以上问题,提供一个抽象基类作为对外接口。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// InterFace_A.h (类 A 的对外接口,公开)
class InterFace_A
{
public:
virtual void f(int) = 0;
};
// B.cpp (类 A 的某个使用者)
#include "InterFace_A.h"
void func(InterFace_A *p)
{
p->f(2); // O
*((int *)p) = 1; // 这里不知道 p 所指向的对象有哪些数据成员,
// 因此,该操作不知道它访问的是什么数据成员
}
多继承与虚基类
多继承是指派生类可以有一个以上的直接基类。多继承的派生类定义格式为:
class <派生类名>: [<继承方式>] <基类名1>, [<继承方式>] <基类名2>, ...。
基类的声明次序决定对基类数据成员的存储安排、对基类构造函数 / 析构函数的调用次序。
多继承带来的两个主要问题:名冲突问题、重复继承问题。
名冲突问题
解决方法:基类名受限,即指出所用的名字来自哪个基类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class A
{
public:
void f();
};
class B
{
public:
void f();
};
class C : public A, public B
{
public:
void func()
{
A::f(); // OK,调用 A 的 f。
B::f(); // OK,调用 B 的 f。
}
};
C c;
c.A::f(); // OK,调用 A 的 f。
c.B::f(); // OK,调用 B 的 f。
重复继承问题
下面的类 D 从类 A 继承两次,称为重复继承:
1
2
3
4
5
6
7
8
9
class A { int x; ... };
class B: public A {...};
class C: public A {...};
class D: public B, public C {...};
// A A
// | |
// B C
// \ /
// D
如果要求类 D 中只有一个 x,则应把 A 定义为 B 和 C 的虚基类:
1
2
3
4
5
6
7
8
9
class A { int x; ... };
class B: virtual public A {...}; // 虚基类
class C: virtual public A {...}; // 虚基类
class D: public B, public C {...};
// A
// / \
// B C
// \ /
// D
这样,D 类的对象 d 就只有一个 x 了。
面向对象的输入 / 输出
主要分为三类:面向控制台的 I/O、面向文件的 I/O、面向字符串变量的 I/O。
面向文件的 I/O
对文件数据进行读写,一般要按下面的过程进行:
打开文件:把程序内部的一个表示文件的变量/对象与外部的一个具体文件关联起来,并创建内存缓冲区。
文件读/写:存取文件中的内容。
关闭文件:把暂存在内存缓冲区中的内容写入文件,并归还打开文件时申请的内存资源(包括内存缓冲区)。
文件的输出操作:
打开文件:创建
ofstream类的一个对象,并将它与外部文件建立联系。1 2 3 4 5
// 直接方式:创建对象时建立与外部文件的联系 ofstream out_file("d:\\myfile.txt",ios::out); // 间接方式:先用默认构造创建一个对象,然后用 open 建立与外部文件的联系 ofstream out_file; out_file.open("d:\\myfile.txt",ios::out);
打开方式:
ios::out:打开一个外部文件用于写操作。如果外部文件已存在,则首先把它的内容清除;否则,先创建该外部文件(内容为空)。ios::out是默认打开方式。ios::app:打开一个外部文件用于添加操作,不清除文件内容,文件位置指针在末尾。如果外部文件不存在,则先创建该外部文件(内容为空)。ios::out | ios::binary或ios::app | ios::binary:按二进制方式打开文件(默认的是文本方式)。
判断打开操作是否成功:可采用
!out_file.is_open()或out_file.fail()或!out_file,后接失败处理过程。输出数据:使用插入操作符
<<或ofstream类的成员函数来进行文件数据的输出操作。1 2 3 4 5 6
int x = 12; double y = 12.3; // 按文本方式输出数据 ofstream out_file("d:\\myfile.txt", ios::out); if (!out_file) { exit(-1); } out_file << x << ' ' << y << endl; // 输出:12 12.3
关闭文件:
out_file.close();,目的是把文件内存缓冲区的内容写到磁盘文件中,并归还打开文件时申请的资源。
文件的输入操作:
打开文件:创建
ifstream类的一个对象,并将它与外部文件建立联系。打开方式: ios::in或 ``ios::inios::binary`。 判断打开操作是否成功。
输入数据:注意,从文件输入必须要知道文件中数据的存储方式和格式。
1 2 3 4 5 6
int x; double y; // 按文本方式输入数据 ifstream in_file("D:\\myfile.txt", ios::in); if (!in_file) { exit(-1); } in_file >> x >> y;
读取数据过程中,有时需要判断是否正确读入了数据(尤其是在文件末尾处),可以调用函数
bool ios::fail() const;,返回true表示文件操作失败;返回false表示文件操作成功。例如,从文件读入一系列整型数:
1 2 3 4 5 6 7 8
int x; in_file >> x; // 读入第一个数 while (!in_file.fail()) { ...... in_file >> x; // 读入下一个数 } in_file.close();
- 关闭文件。
异常处理
程序的错误通常包括:
- 语法错误:指程序的书写不符合语言的语法规则。例如:使用了未定义或未声明的标识符、左右括号不匹配等。这类错误可由编译程序发现。
- 逻辑错误(或语义错误):指程序设计不当造成程序没有完成预期的功能。例如:把两个数相加写成了相乘、排序功能未能正确排序等。这类错误可通过对程序进行静态分析和动态测试发现。
- 运行异常(Exception):指程序设计对程序运行环境考虑不周而造成的程序运行错误。例如:“x / y” 操作给 y 输入了 “0”、由内存空间不足导致的内存访问错误、数组下标越界错误、多任务环境可能导致的文件操作错误等。在程序运行环境正常的情况下,是不会出现运行异常的错误的。
C++ 结构化异常处理机制
- 启动异常处理机制:把有可能出现异常的一系列操作放在一个
try语句块中; - 生成异常对象:如果
try语句块中的某个操作在执行中发现了异常,则通过执行一个throw语句生成一个异常对象,接在throw之后的操作不再进行; - 捕获异常对象:生成的异常对象由程序中能够处理这个异常的地方通过
catch语句块来捕获并处理之。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void f()
{
throw 1;
throw 1.0;
throw "abcd";
}
void g()
{
try { f(); }
catch (int) { <语句序列 1> } // 处理函数 f 中的 throw
catch (double) { <语句序列 2> } // 处理函数 f 中的 throw 1.0
catch (char *) { <语句序列 3> } // 处理函数 f 中的 throw "abcd"
...
}
基于断言的程序调试
断言是一个逻辑表达式,它描述了程序执行到断言处应满足的条件。如果条件满足则程序继续执行下去,否则程序异常终止。格式为:assert(<表达式>);,<表达式> 一般为一个关系/逻辑表达式。
模板
一个程序实体能对多种类型的数据进行操作的特性称为类属。具有类属特性的程序实体通常有:类属函数、类属类。基于具有类属特性的程序实体进行程序设计的技术称为:泛型程序设计。
函数模板
函数模板是指带有类型参数的函数定义,格式如下:
1
2
template <class T1, class T2, ...>
<返回值类型> <函数名>(<参数表>) {...}
例如,用函数模板实现类属排序函数:
1
2
3
4
5
6
7
8
9
10
11
12
template <class T1, class T2>
void sort(T1 elements[], unsigned int count, T2 cmp)
{
// 调用 cmp 来比较第 i 个和第 j 个元素的大小
if (!cmp(elements[i], elements[j]))
{
... // 交换元素次序
}
}
int a[100];
sort(a, 100, [](int &x1, int &x2){ return x1 < x2; });
// 实例化:用 int 去替代 T1,用 λ 表达式所属类型去替代 T2
要使用函数模板所定义的函数,首先必须要对函数模板进行实例化,即:给模板参数提供一个具体的类型,从而生成具体的函数。函数模板的实例化通常是隐式的,由编译程序根据函数调用的实参类型,自动地把函数模板实例化为具体的函数(模板实参推导)。
除了类型参数外,函数模板还可以带有非类型参数,这样的函数模板在使用时需要显式实例化。例如:
1
2
3
4
5
6
7
template <class T, int size> // size 为一个 int 型的普通参数
void func(T a)
{
T temp[size];
...
}
func<int,10>(1); //实例化成模板函数 func(int a),其中的 size 为 10
类模板
如果一个类的成员类型不同,但数据表示和功能相同,则该类称为类属类。在 C++ 中,类属类用类模板实现。类模板的格式为:
1
2
template <class T1, class T2, ...>
class <类名> { ... };
例如,用类模板实现类属的栈类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <class T>
class Stack
{
T buffer[100];
int top;
public:
Stack() { top = -1; }
void push(const T &x);
void pop(T &x);
};
template <class T>
void Stack<T>::push(const T &x) {}
template <class T>
void Stack<T>::pop(T &x) {}
类模板定义了若干个类,在使用这些类之前需要对类模板进行实例化。类模板的实例化需要在程序中显式地指出。例如:vector<int> vec;。通常把模板的定义和实现都放在头文件中。
基于 STL 的编程
- 利用
max_element计算并输出容器v中的最大元素:*max_element(v.begin(), v.end()); - 利用
accumulate计算并输出容器v中所有元素的和:accumulate(v.begin(), v.end(), <初值>); - 利用
for_each依次输出容器v中的元素:for_each(v.begin(),v.end(), print});
迭代器
迭代器(iterator)属于一种智能指针,它们指向容器中的元素,用于对容器中的元素进行访问和遍历。可通过容器类的成员函数 begin 和 end 等获得容器的首尾迭代器。不同的容器所提供的迭代器类型会有所不同:
对于
vector、deque以及basic_string,为随机访问迭代器;对于
list、map/multimap以及set/multiset,为双向迭代器;对于
queue、stack和priority_queue,不支持迭代器!
函数式程序设计
函数式程序设计是指把程序组织成一组数学函数,计算过程体现为基于一系列函数应用(把函数作用于数据)的表达式求值。基本手段有:递归(尾递归)、过滤 / 映射 / 规约操作、部分函数应用、柯里化。
递归和尾递归
在函数式编程中,重复操作不采用迭代(循环),而是采用递归。由于函数递归调用深度要受栈空间的限制,并且递归调用效率低,因此,函数式编程常采用尾递归:递归调用是函数执行的最后一步操作,递归调用回来不再做其它事了。
例如求第 n 个 fibonacci 数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// 普通递归 int fib(int n) { if (n == 1 || n == 2) return 1; else return fib(n - 2) + fib(n - 1); } // 尾递归 int fib(int n, int a, int b) { if (n == 1) return a; else if (n == 2) return b; else return fib(n - 1, b, a + b); // 递归调用回来不再做其它事 }
例如求 n 的阶乘:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// 普通递归 int factorial(int n) { if (n == 0 || n == 1) return 1; else return n * factorial(n - 1); } // 尾递归 int factorial(int n, int acc = 1) { if (n == 0 || n == 1) return acc; else return factorial(n - 1, n * acc); }
例如求和:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// 普通递归 int sum_recursive(int n) { if (n == 1) return 1; else return n + sum_recursive(n - 1); } // 尾递归 int sum_tail_recursive(int n, int acc = 0) { if (n == 0) return acc; else return sum_tail_recursive(n - 1, acc + n); }
其他
成员初始化表
可以在构造函数的函数头和函数体之间加入一个成员初始化表来对常量和引用数据成员进行初始化。
格式示例:MyClass() : x(0), z(x), y(1) {}
可变参数的函数
函数至少给出一个参数,该参数用于指出其它参数的个数和类型,否则该函数只能接收固定类型的数据。通过 va_list, va_start, va_arg, va_end 来实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int sum(int count, ...) // 参数 count 用于指出可变参数的个数
{
va_list args; // args 为指向可变参数的指针
va_start(args, count); // 初始化 args 使其指向第一个可变参数
int s = 0, value;
for (int i = 0; i < count; ++i)
{
value = va_arg(args, int); // 获得 args 指向的 int 型参数,
// 并使得 args 指向下一个参数
s += value;
}
va_end(args); // 重置可变参数指针 args
return s;
}
输出操纵符
| 常用输出操纵符 | 含义 |
|---|---|
endl | 输出换行符,并执行 flush 操作 |
flush | 使输出缓存中的内容立即输出 |
dec | 十进制输出 |
oct | 八进制输出 |
hex | 十六进制输出 |
setprecision(int n) | 设置浮点数的精度(由输出格式决定是有效数字的个数还是小数点后数字的位数) |
setiosflags(long flags) / resetiosflags(long flags) | 设置/重置输出格式,flags 的取值可以是:ios::scientific(以指数形式显示浮点数),ios::fixed(以小数形式显示浮点数)等 |