C++快速入门(二)

下面介绍一系列 C++ 和面向对象编程技术中的一些比较高级的概念:静态对象和静态方法,虚方法,抽象方法和多态等。

1. 静态属性和静态方法

前面的例子中,使用的都是对象(某个类的实例)来调用方法,每个方法只处理调用它的那个对象所包含的数据,所有的数据都属于同一个对象。

如果我们需要的功能或数据不属于某个特性的对象,而是属于整个类,该怎么办?

假如我们需要统计一下有多少只活的动物,那么就需要一个计数器,每诞生一只就 +1 ,每挂掉一只,就 -1。

首先想到的是使用一个全局变量,但是任何代码都能改变该计数器,这很危险。所以非必要时不要用全局变量。

我们真正需要的是一个只在创建或删除对象的时候才允许访问的计数器。使用 C++ 的静态属性和静态函数才能完美解决。

C++ 允许我们把一个或多个成员声明为属于某个类,而不是仅属于该类的对象。这么做的好处是程序员可以在没有创建任何对象的情况下调用有关的方法。另一个好处是能够让有关的数据仍在该类的所有对象间共享。

创建一个静态属性和静态方法:只需要在它的声明前加上 static 保留字即可。

代码如下:

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <iostream>
#include <string>

class Pet {
public:
Pet(std::string theName);
~Pet();

static int getCount();

protected:
std::string name;

private:
static int count;
};

class Dog : public Pet {
public:
Dog(std::string theName);
};

class Cat : public Pet {
public:
Cat(std::string theName);
};

int Pet::count = 0; // 注意这一句话做了两件事:1. 为变量count分配空间; 2. 将count赋值为0.

Pet::Pet(std::string theName) {
name = theName;
count++;

std::cout << "有一只宠物出生了,名字叫做:" << name << std::endl;
}

Pet::~Pet() {
count--;
std::cout << name << "挂掉了\n";
}

int Pet::getCount() {
return count;
}

Dog::Dog(std::string theName) : Pet(theName) {}

Cat::Cat(std::string theName1) : Pet(theName1) {}

int main() {
Dog dog("Jerry");
Cat cat("Tom");

std::cout << "\n已经诞生了" << Pet::getCount() << "只宠物!\n\n";

{ // 括号是为了让析构函数及时执行
Dog dog_2("Jerry_2");
Cat cat_2("Tom_2");

std::cout << "\n目前,有" << Pet::getCount() << "只宠物!\n\n";
}

std::cout << "\n还剩" << Pet::getCount() << "只宠物!\n\n";

return 0;
}

如果注释掉 main 函数中看似多余的大括号,会导致代码的运行顺序发生改变。

静态方法与 this 指针

潜规则:

  • 静态成员是所有对象共享的,所以不能在静态方法里访问非静态的元素。
  • 非静态方法可以访问类的静态成员,也可以访问类的非静态成员。

this 指针是一个类的自动生成、自动隐藏的私有成员,它存在于类的非静态成员函数中,指向被调用函数所在的对象的地址。

当一个对象被创建时,该对象的 this 指针就自动指向对象数据的首地址。

举例说明:

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
#include <iostream>

class Point {
public:
Point(int a, int b) {
x = a;
y = b;
}

void MovePoint(int a, int b) {
x = a;
y = b;
}

void print() {
std::cout << "x=" << x << " y=" << y << std::endl;
}

private:
int x, y;
};

int main() {
Point point1(10, 10);
point1.MovePoint(1, 2);
point1.print();

return 0;
}

上面这个例子中:

  • 当对象 point1 调用 MovePoint 函数的原型事实上应该是 void MovePoint(Point *this, int a, int b);

    第一个参数是指向该类对象的一个指针,我们在定义成员函数时没看见是因为这个参数在类中是隐含的。

  • 这样 point1 的地址传递给了 this ,所以在 MovePoint 函数中便可以显式的写成:void MovePoint(int a, int b) { this->x = a; this->y = b; }

  • 即可以知道,point1 调用该函数之后,point1 的数据成员被调用并更新了值。

改写前面的列子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
'''
Dog::Dog(std::string theName) : Pet(theName) {
std::cout << "this:" << this << std::endl;
}
'''
int main() {
'''
Cat cat("Tom");

std::cout << "dog:" << &dog << std::endl; // changed part
std::cout << "\n已经诞生了" << Pet::getCount() << "只宠物!\n\n";

'''
}

运行结果如下,可以看出,第一只狗的 this 地址都是对象 dog 的地址,第二只勾搭地址为 &dog_2 与第一个地址有偏移。

在任何一个方法里都可以使用 this 指针。从本质上讲,C++ 中的对象其实是一种不仅包含了变量还包含着一些函数的特殊结构。

在程序运行时,对象的属性(变量)和方法(函数)都是保存在内存里,这就意味着它们各自都有与之相关联的地址。这些地址都可通过指针来访问,而 this 指针毋庸置疑是保存着对象本身的地址。

每当我们调用一个方法的时候,this 指针就会随着你提供的输入参数被秘密的传递给那个方法。正因如此,我们才能在方法里像使用一个局部变量那样使用 this 指针。

因为静态方法不是属于某个特定的对象,而是由全体对象共享的,这就意味着它们无法访问 this 指针。所以,我们无法在静态方法里访问非静态的类成员。

需要注意:

  • 在使用静态属性的时候,千万别忘记为它们分配内存。具体做法很简单,只要在类声明的外部对静态属性做出声明(就像声明一个变量那样)即可。

  • 静态方法也可以使用一个普通方法的调用语法来调用,但建议不要这样,那会让代码变得更糟糕!

    建议使用:ClassName::methodName();

    不要使用:objectName.methodName(); //非静态成员使用对象直接调用

2. 虚方法

事实上在 C 和 C++ 中,我们完全可以在没有创建变量的情况下为有关数据分配内存。也就是直接创建一个指针并让它指向新分配的内存块:

1
2
3
4
int *pointer = new int;
*pointer = 10;
std::cout << *pointer;
delete pointer;

最后一步非常必要和关键,这时因为程序不会自动释放内存,程序中的每一个 new 操作都必须有一个与之对应的 delete 操作!

举个例子引出虚方法:

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#include <iostream>
#include <string>

class Pet {
public:
Pet(std::string theName);

void eat();
void sleep();
void play();
protected:
std::string name;
};

class Cat : public Pet {
public:
Cat(std::string theName);

void climb();
void play();
};

class Dog : public Pet {
public:
Dog(std::string theName);

void bark();
void play();
};

Pet::Pet(std::string theName) {
name = theName;
}

void Pet::eat() {
std::cout << name << "正在吃东西!\n";
}

void Pet::sleep() {
std::cout << name << "正在睡大觉!\n";
}

void Pet::play() {
std::cout << name << "正在玩儿!\n";
}

Cat::Cat(std::string theName) : Pet(theName) {}

void Cat::climb() {
std::cout << name << "正在爬树!\n";
}

void Cat::play() {
Pet::play();
std::cout << name << "玩毛线球!\n";
}

Dog::Dog(std::string theName) : Pet(theName) {}

void Dog::bark() {
std::cout << name << "正在犬吠!\n";
}

void Dog::play() {
Pet::play();
std::cout << name << "正在追那只该死的猫!\n";
}

int main() {
Pet *cat = new Cat("加菲"); // 定义一个基类的指针指向子类的
Pet *dog = new Dog("欧迪");

cat->sleep();
cat->eat();
cat->play();

dog->sleep();
dog->eat();
dog->play();

delete cat;
delete dog;

return 0;
}

可以看到这里程序运行只运行了基类的方法,子类的方法没有执行。我们在 Cat 和 Dog 类中对 play() 方法进行了覆盖,但实际上调用的是 Pet::play() 方法而不是那两个覆盖的版本。

当子类中存在覆盖基类的方法时,我们想用指针调用子类方法,就会出现问题,结果是调用了基类的方法。C++ 允许我们定义一个基类的指针指向子类的数据空间。在编译的时候,编译器会认为这里是一个 BaseClass 型的指针,因此也就认为指针中的方法是基类方法。

虚方法(virsual method)

引发问题的源头就是我们使用了 new 在程序运行的时候才为 dog 和 cat 分配 Dog 类型和 Cat 类型的指针。这是在运行时才分配的类型,和它们在编译时的类型是不一样的。!

为了让编译器知道它应该根据这两个指针在运行时的类型而有选择地调用正确地方法Dog::play()Cat::play() ,我们必须把这些方法声明为虚方法。

声明一个虚方法地语法非常简单,只要在其原型前边加上 virtual 保留字即可。

1
virtual void play();

另外,虚方法是继承的,一旦在基类里把某个方法声明为虚方法,在子类里就不可能在把它声明为一个非虚方法了。

TIPS

  • one:如果拿不准要不要把某个方法声明为虚方法,那么就把它声明为虚方法好了。
  • two:在基类里把所有的方法都声明为虚方法会让最终生成的可执行代码的速度变得稍微慢一些,但好处是可以一劳永逸地确保程序的行为符合你的预期。
  • three:在实现一个多层次的类继承关系的时候,最顶级的基类应该只有虚方法。
  • 析构器都是虚方法!从编译的角度看,它们只是普通的方法。如果它们不是虚方法。如果它们不是虚方法,编译器就会根据它们在编译时的类型而调用基类中的版本(析构器),那样往往会造成内存泄漏!

再来个例子:

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
#include <iostream>

class animal {
public:
void sleep() {
std::cout << "animal sleep" << std::endl;
}
void breathe() {
std::cout << "animal breathe" << std::endl;
}
};

class fish : public animal {
public:
void breathe() {
std::cout << "fish bubble" << std::endl;
}
};

int main() {
fish fh;
animal *pAn = &fh;

pAn->breathe();

return 0;
}

可以看到此处只调用了基类的方法,如果讲基类中的方法改为虚函数:

1
2
3
4
5
6
7
8
9
class animal {
public:
void sleep() {
std::cout << "animal sleep" << std::endl;
}
virtual void breathe() { // 声明虚函数
std::cout << "animal breathe" << std::endl;
}
};

3. 抽象方法

抽象方法(abstract method,也可以成为一个纯虚函数)是面向对象编程技术的另一个核心概念,在设计一个多层次的类继承关系时常会用到。

把某个方法声明为一个抽象方法等于在高速编译器这个方法必不可少,但我现在(在这个基类里)还不能为他提供一个实现。

其实前面的 Pet::play() 就是一个抽象方法。实现中每种宠物都有自己的玩法,所以在基类中没必要为其定义一个实现,每个子类中的实现各不相同,更适合使用抽象方法。

抽象方法的语法很简单:在声明一个虚方法的基础上,在原型的末尾加上”=0“。(告诉编译器不用浪费时间在这个类里寻找这个方法的实现。)

示例如下:

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <iostream>
#include <string>

class Pet {
public:
Pet(std::string theName);
virtual void eat();
virtual void sleep();
virtual void play() = 0;

protected:
std::string name;
};

class Cat : public Pet {
public:
Cat(std::string theName);

void climb();
void play();
};

class Dog : public Pet {
public:
Dog(std::string theName);

void bark();
void play();
};

Pet::Pet(std::string theName) {
name = theName;
}

void Pet::eat() {
std::cout << name << "正在吃东西!\n";
}

void Pet::sleep() {
std::cout << name << "正在睡觉。\n";
}

Cat::Cat(std::string theName) : Pet(theName) {}

void Cat::climb() {
std::cout << name << "正在爬树!\n";
}

void Cat::play() {
std::cout << name << "玩儿毛线球\n";
}

Dog::Dog(std::string theName) : Pet(theName) {}

void Dog::bark() {
std::cout << name << "旺~旺~\n";
}

void Dog::play() {
std::cout << name << "改不了吃翔\n";
}

int main() {
Pet *cat = new Cat("猫猫");
Pet *dog = new Dog("狗狗");

// cat->climb(); // 父类指针无法调用子类的特有方法
cat->eat();
cat->play();

// dog->bark();
dog->eat();
dog->play();

return 0;
}

从结果看到,通过使用抽象方法,可以使用基类指针调用子类覆盖的方法。

多态性: 用一个名字定义不同的函数,调用同一个名字的函数,却执行不同的操作,从而实现“一个接口,多种方法”。

多态是如何实现绑定的?

  • 编译时的多态性:通过重载实现

    特点:速度快

  • 运行时的多态性:通过虚函数实现

    特点:高度灵活和抽象

再看一个例子:

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
#include <iostream>

class ClxBase {
public:
ClxBase() {};

virtual ~ClxBase() {};

virtual void doSomething() {
std::cout << "Do something in class ClxBase!\n";
}
};

class ClxDerived : public ClxBase {
public:
ClxDerived() {};

~ClxDerived() {
std::cout << "Output from the destructor of class ClxDerived!\n";
};

void doSomething() {
std::cout << "Do something in class ClxDerived!\n";
};
};

int main() {
ClxBase *pTest = new ClxDerived;

pTest->doSomething();

delete pTest; // 释放空间

return 0;
}
  • 一般析构函数里都是释放内存资源的,而析构函数不被调用的话就会造成内存泄漏。

  • 析构器都是虚方法是为了当一个基类的指针删除一个派生类的对象时,派生类的析构函数可以被正确调用。

  • 当类中有虚函数的时候,编译器会给类添加一个虚函数表,里面存放着虚函数指针。为了节省资源,只有当一个类被用来作为基类的时候,我们才把析构函数写成虚函数。

4. 运算符重载

重载 就是重新赋予新的含义。函数重载 是对一个已有的函数赋予新的含义,使之实现新功能。

运算符也可以重载。运算符重载的方法是定义一个重载运算符的函数,在需要执行被重载的运算符时,系统就自动调用该函数,以实现相应的运算。也就是说,运算符重载是通过定义函数实现的。运算符重载实质上是函数的重载。

重载运算符的函数一般格式如下:

1
2
3
4
5
6
7
8
函数类型 operator 运算符名称(形参列表) {
对运算符的重载处理
}

// 例如
int operator+(int a, int b) {
return (a - b);
}

看一个用类方法实现的复数加法:

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
47
48
49
50
51
#include <iostream>

class Complex {
public:
Complex();
Complex(double r, double i);
Complex complex_add(Complex &d);
void print();

private:
double real;
double imag;
};

Complex::Complex() {
real = 0;
imag = 0;
}

Complex::Complex(double r, double i) {
real = r;
imag = i;
}

Complex Complex::complex_add(Complex &d) {
Complex c;

c.real = real + d.real;
c.imag = imag + d.imag;

return c;
}

void Complex::print() {
std::cout << "(" << real << ", " << imag << "i)\n";
}

int main() {
Complex c1(3, 4), c2(5, -10), c3;

c3 = c1.complex_add(c2);

std::cout << "c1 = ";
c1.print();
std::cout << "c2 = ";
c2.print();
std::cout << "c1 + c2 = ";
c3.print();

return 0;
}

如果想进行连续的加法,调用方法函数就会很复杂,如下

1
2
   Complex c1(3, 4), c2(5, -10), c3(2, 16), c4;
c4 = c3.complex_add( c1.complex_add(c2) );

如果对 + 进行重载,就可以向使用正常运算符一样,很方便:

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
47
48
49
50
51
52
53
#include <iostream>

class Complex {
public:
Complex();
Complex(double r, double i);
Complex operator + (Complex &d); // + 运算符重载
void print();

private:
double real;
double imag;
};

Complex::Complex() {
real = 0;
imag = 0;
}

Complex::Complex(double r, double i) {
real = r;
imag = i;
}

Complex Complex::operator + (Complex &d){
Complex c;

c.real = real + d.real;
c.imag = imag + d.imag;

return c;
}

void Complex::print() {
std::cout << "(" << real << ", " << imag << "i)\n";
}

int main() {
Complex c1(3, 4), c2(5, -10), c3(2, 16), c4;

c4 = c1 + c2 + c3; // 直接运算即可

std::cout << "c1 = ";
c1.print();
std::cout << "c2 = ";
c2.print();
std::cout << "c3 = ";
c3.print();
std::cout << "c1 + c2 + c3 = ";
c4.print();

return 0;
}

还可以对运算符重载函数 operator + 改写得更简练一些:

1
2
3
Complex Complex::operator+(Complex &d) {
return Complex(real + d.real, imag + d.imag);
}

一些规则:

C++ 不允许用户自己定义新得运算符,只能对已有得 C++ 运算符进行重载。

运算符除以下五个外,其他都可以重载:

  • . 成员访问运算符
  • .* 成员指针访问运算符
  • :: 域运算符
  • sizeof 尺寸运算符
  • ?: 条件运算符

重载不能改变以下特性:

  • 运算符运算对象(操作数)个数
  • 运算符得优先级
  • 运算符的结合性
  • 重载运算符的函数不能有默认的参数
  • 重载的运算符必须和用户定义的自定义类型的对象一起使用,其参数至少应该有一个是类对象或类对象的引用。(即参数不能全部都是 C++ 的标准类型,这样约定是为了防止用户修改用于标准类型结构的运算符性质。)

运算符重载函数作为类友元函数

为什么 ”+“ 运算符是双目运算符,而例子中的重载函数只有一个参数?

实际上,运算符重载函数有两个参数,有一个参数是隐含着的,运算符函数是用 this 指针隐式地访问类对象的成员。

1
2
3
return Complex(real + c2.real, imag + c2.imag);
return Complex(this->real + c2.real, this->imag + c2.imag);
return Complex(c1.real + c2.real, c1.imag + c2.imag);

例子 c1 + c2 中,编译系统把它解释为:c1.operator+(c2) , c1 调用运算符重载函数,并以表达式中第二个参数(运算符右侧的类对象 c2)作为函数实参。

运算符重载函数除了可以作为类的成员函数外,还可以是非成员函数:放在类外,作为 Complex 类的友元函数存在。

对上面的例子做修改,只列出关键部分:

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
'''
class Complex {
public:
Complex();
Complex(double r, double i);
friend Complex operator+(Complex &c, Complex &d);
// Complex complex_add(Complex &d);
void print();

private:
double real;
double imag;
};
'''
// 注意,这里作为友元函数,不属于 Complex,记得别写 :: 咯哦~
Complex operator+(Complex &c, Complex &d) { // 不在 Complex 作用域下,是一个单独的函数
return Complex(c.real + d.real, c.imag + d.imag);
}
'''
int main() {
Complex c1(3, 4), c2(5, -10), c3(2, 16), c4;

c3 = c1 + c2; // 此时只能两个数相加,不能连续相加
// (不知道是不是我写的有问题,这样来看,还是将运算符函数作为成员函数效果更好。)
'''
}

运算符函数如果只是一个普通函数,它是没有权利访问 Complex 类的私有成员的。所以要设置为友元函数。

由于友元函数的使用会破坏类的封装,因此从原则上说,要尽量将运算符函数作为成员函数。

实现分数 + - * / 的重载

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
#include <iostream>
#include <string>
#include <cmath>

class Fraction {
public:
Fraction();
Fraction(int x, int y);

void print();

Fraction operator + (Fraction &num);
Fraction operator - (Fraction &num);
Fraction operator * (Fraction &num);
Fraction operator / (Fraction &num);

private:
void normalize(); // 负责对输入分数的简化处理

int molecular;
int denominator;
};

Fraction::Fraction() {
molecular = 0;
denominator = 0;
}

Fraction::Fraction(int x, int y) {
molecular = x;
denominator = y;

normalize();
}

// normalize() 对分数进行简化操作包括:
// 1. 只允许分子为负数,如果分母为负数则把负号挪到分子部分
// 2. 利用欧几里得算法(辗转求余)将分数进行简化:2/10 => 1/5
void Fraction::normalize() {
// 1. 确保分母为正
if (denominator < 0) {
molecular = -molecular;
denominator = -denominator;
}

// 2. 欧几里得算法
int a = abs(molecular);
int b = abs(denominator);

// 求最大公约数
while (b > 0) {
int t = a % b;
a = b;
b = t;
}

// 分子、分母分别除以最大公约数
molecular /= a;
denominator /= a;
}

void Fraction::print() {
std::cout << molecular << " " << "/" << denominator << " " << std::endl;
}

Fraction Fraction::operator + (Fraction &num) {
return Fraction(molecular*num.denominator + num.molecular*denominator, denominator*num.denominator);
}

Fraction Fraction::operator - (Fraction &num) {
return Fraction(molecular*num.denominator - num.molecular*denominator, denominator*num.denominator);
}

Fraction Fraction::operator * (Fraction &num) {
return Fraction(molecular * num.molecular, denominator * num.denominator);
}

Fraction Fraction::operator / (Fraction &num) {
return Fraction(molecular * num.denominator, denominator * num.molecular);
}

int main() {
Fraction num1(3, 4), num2(2, 3), num3(1, 2), num4;

std::cout << "num1 = ";
num1.print();
std::cout << "num2 = ";
num2.print();
std::cout << "num3 = ";
num3.print();
std::cout << "num1 + num2 = ";
num4 = num1 + num2;
num4.print();
std::cout << "num1 - num2 = ";
num4 = num1 - num2;
num4.print();
std::cout << "num1 * num2 = ";
num4 = num1 * num2;
num4.print();
std::cout << "num1 / num2 = ";
num4 = num1 / num2;
num4.print();
std::cout << "num1 + num2 + num3 = ";
num4 = num1 + num2 + num3;
num4.print();

return 0;
}

如果不调用构造函数中的 normalize() ,结果不会被约分

如果在构造函数中调用 normalize() ,结果被约分(可能是返回运算结果时,以 Fraction() 的形式返回,相当于创建了一个新的对象,调用构造函数,将计算结果,即新的输入进行约分。)。

5. 重载 << 运算符

下面是 operator << () 函数的原型:

1
std::ostream& operator << (std::ostream& os, Fraction f);
  • 第一个输入参数 os 是将要向它(<<)写数据的流,它是以“引用传递”方式传递的
  • 第二个输入参数是打算写入到 os 流中的数据值,不同的 operator << () 重载函数就是因为这个输入参数才相互区别的。
  • 返回类型是 ostream 流的引用。一般来说,在调用 operator << () 重载函数时传递给它的是哪一个流,它返回的就应该是那个流的一个引用。

将上面例子中的 Fraction.print() 改为 << 的重载:

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
#include <iostream>
#include <string>
#include <cmath>

class Fraction {
public:
Fraction();
Fraction(int x, int y);

Fraction operator + (Fraction &num);
Fraction operator - (Fraction &num);
Fraction operator * (Fraction &num);
Fraction operator / (Fraction &num);

private:
void normalize(); // 负责对输入分数的简化处理

int molecular;
int denominator;

friend std::ostream& operator << (std::ostream& os, Fraction f);
};

''' // 中间部分不变,去掉 print() 方法的定义即可

std::ostream& operator << (std::ostream& os, Fraction f);

int main() {
Fraction num1(3, 4), num2(2, 3), num3(1, 2), num4;

std::cout << "num1 + num2 + num3 = ";
num4 = num1 + num2 + num3;
std::cout << num4 << std::endl;

return 0;
}

std::ostream& operator << (std::ostream& os, Fraction f) {
os << f.molecular << "/" << f.denominator;
return os;
}

可以看到,当 << 遇到标准输入时,直接按照 std 中的输入流方式流入 cout ,而遇到 Fraction 类,则调用重载的方法流入 cout

6. 多继承

一个子类同时继承了多个父类(基类)的特性。

例如,学校里有老师,有学生,还有一些助教(既是学生也是老师)。

下面例子中来演示:创建一个由 PersonTeacherStudent 类,还有 TeachingStudent 类构成的层次结构。

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
#include <iostream>
#include <string>

class Person {
public:
Person(std::string theName);

void introduce();

protected:
std::string name;
};

class Teacher : public Person {
public:
Teacher(std::string theName, std::string theClass);

void teach();
void introduce(); // 覆盖

protected:
std::string classes;
};

class Student : public Person {
public:
Student(std::string theName, std::string theClass);

void attendClass();
void introduce();

protected:
std::string classes;
};

// 注意继承的多个父类的权限都要声明,默认的继承方式是 private
class TeachingStudent : public Teacher, public Student {
public:
TeachingStudent(std::string theName, std::string classTeaching, std::string classAttending);

void introduce();
};

Person::Person(std::string theName) {
name = theName;
}

void Person::introduce() {
std::cout << "大家好,我是" << name << "。\n\n";
}

Teacher::Teacher(std::string theName, std::string theClass) : Person(theName) {
classes = theClass;
}

void Teacher::teach() {
std::cout << name << "教" << classes << "。\n\n";
}

void Teacher::introduce() {
std::cout << "大家好,我是" << name << ",我教" << classes << "。\n\n";
}

Student::Student(std::string theName, std::string theClass) : Person(theName) {
classes = theClass;
}

void Student::attendClass() {
std::cout << name << "加入" << classes << "学习。\n\n";
}

void Student::introduce() {
std::cout << "大家好,我是" << name << ",我在" << classes << "学习。\n\n";
}

TeachingStudent::TeachingStudent(std::string theName, std::string ClassTeaching, std::string classAttending)
: Teacher(theName, ClassTeaching), Student(theName, classAttending) {}

void TeachingStudent::introduce() {
std::cout << "大家好,我是" << Student::name << "。我教" << Teacher::classes << ",";
std::cout << "同时我在" <<Student::classes << "学习。\n\n";
}

int main() {
Teacher teacher("小甲鱼", "C++入门班");
Student student("番茄元", "C++入门班");
TeachingStudent teachingStudent("小花", "C++入门班", "C++进阶班");

teacher.introduce();
teacher.teach();
student.introduce();
student.attendClass();
teachingStudent.introduce();
teachingStudent.teach();
teachingStudent.attendClass();

return 0;
}

7. 虚继承

虚继承即只能把父类的属性继承给第一代的子类,子类不能将虚继承的属性再继承给后代。

例如,上面的例子中,小花是助教,同时拥有学习班和教学班是合理的,但是如果同时拥有老师的名字和学生的名字两个不同的名字,那就挺人格分裂的。这种问题可以使用虚继承避免,TeacherStudent 类虚继承 Person 类的名字,该名字不能传给 TeachingStudent 类,但是将其他的自身属性向其“遗传”。那 TeachingStudent 类的名字从何而来,当然是自己从 Person 类中继承啦。

实现如下:仅显示修改部分

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
class Teacher : virtual public Person { // 虚继承
public:
Teacher(std::string theName, std::string theClass);

void teach();
void introduce(); // 覆盖

protected:
std::string classes;
};

class Student : virtual public Person { // 虚继承
public:
Student(std::string theName, std::string theClass);

void attendClass();
void introduce();

protected:
std::string classes;
};

TeachingStudent::TeachingStudent(std::string theName, std::string ClassTeaching, std::string classAttending)
: Teacher(theName, ClassTeaching),
Student(theName, classAttending),
Person(theName) {} // 自己从 Person 类中继承 name 属性,此时就没有了扩展出两个名字的可能

8. 错误处理与调试

程序出错可以分为两大类:编译时错误(complie-time error)和运行时错误(run-time error)

相比之下,编译时错误显然是比较轻的,因为在编译时将会告诉你发生了什么错。

下面是一些好的编程建议:

编译时错误

  1. 培养并保持一种编程风格。

    命名方式、缩进方式等。

  2. 认真对待编译器给出的错误/警告信息。

  3. 开始写代码前先画流程图,再动手。改代码前先完整地审阅一遍源代码。

  4. 注意检查最基本的语法。

  5. 把可能有问题的代码行改为注释,而不是轻易删除。

  6. 换个环境或开发工具试试。(或许是你强悍的杀毒软件干掉了一些不该干掉的东西。)

  7. 检查是否把所有必要的头文件全部 include 进来。

  8. 留意变量的作用域和命名空间。

    程序代码对变量的访问权限可能导致各种各样的问题。

  9. 休息一下。放松一下,头脑清醒时解决问题效果更佳哦。

  10. 使用调试工具。

    绝大多数 IDE 都有一个内建的调试器,一定要学习使用它并经常使用它。

  11. 做好备份工作,及时保存已经调试好的代码。

  12. 把代码划分成各个模块,用它们(在你能保证它们都没问题的情况下)来搭建新的应用程序,可以减少很多开发和调试时间。

运行时错误

运行时错误往往远比编译时错误更难以查找和纠正,运行时错误一般都不会有正式的出错信息。很少有规律可循。可能换台机子就不好用了。

  1. 培养并保持一种良好的编程风格。

  2. 写代码写好注释,改代码修改注释,不要做无谓的注释。

    你的代码是写给牛逼人看的,不要写过于基础的注释。

  3. 注意操作符的优先级,拿不准就加括号。

    不要对操作顺序做任何假设——在某些场合,++ * -> 之类的高优先级操作符的行为也不见得是你想象的那么高。

  4. 千万不要忘记对用户输入和文件输入进行合法性检查。

    程序员自己是检查不出一些漏洞的,需要其他人用不同的思维来测试。

  5. 不要做任何假设。

    不要想当然的认为用户会按照你的意愿去使用你的程序,一定要足够用户友好。

  6. 把程序划分为一些比较小的单元模块来测试。

让函数返回错误代码

让程序能够自行处理潜在错误的办法之一就是创建一些测试函数:专门测试某种条件并根据测试结果返回一个代码来表示当前函数的执行状态。

1
2
3
4
5
6
7
int myFunction() {
if (condtion) {
return 0; // 满足条件返回 0
} else {
return 1; // 不满足返回 1
}
}

有些程序员喜欢使用异常而不是 return 语句。我们这里不推荐这个例子中所演示的 return 方法,理由是:把各种出错代码进行处理的语句混在程序的主干部分既不利于模块化编程,又容易干扰正常的思考。

下面学习异常处理 assert 函数

9. assert 函数和捕获异常

assert 函数

C 语言和 C++ 都有一个专门为调试而准备的工具函数,就是 assert() 函数。这个函数是在 C 语言的 assert.h 库文件里定义的,所以包含到 C++ 程序中需要使用语句:#include <cassert>

assert() 函数需要有一个参数,它将测试这个输入参数的真 or 假状态:

  • 如果为真,Do nothing!
  • 如果为假,Do something!

我们可以利用 assert() 函数在某个程序里的关键假设不成立时立刻停止改程序的执行并报错,从而避免更严重的问题。

另外,还可以在程序的开发和测试阶段使用大量的 cout 语句报告在程序里正在运行的情况。

注意:程序员看到的提示可以很详细,但是交付给用户的提示必须要专业又清晰,不能轻易中断程序,不能充满技术细节。

异常捕获

为了对付潜在的编程错误(尤其是运行时错误),捕获异常是一种完全不同的办法。

简单说,异常(exception)就是与预期不相符合的反常现象。

基本使用思路:

  1. 安排一些 C++ 代码(try 语句)去尝试可能会失败的事(比如打开一个文件或者申请一些内存)
  2. 如果发生问题,就抛出一个异常(throw 语句)
  3. 再安排一些代码(catch 语句)去捕获这个异常并进行相应的处理

捕获异常的基本语法如下:

1
2
3
4
5
6
try {
// Do something.
// Throw an exception on error.
} catch {
// Do whatever.
}

Pay attention! 每条 try 语句至少要有一条配对的 catch 语句。必须定义 catch 语句以便让它接受一个特定类型的参数。

C++ 还允许我们定义多条 catch 语句,让每条 catch 语句分别对应着一种可能的异常类型:

1
2
3
catch(int e)  	{...}
catch(bool e) {...}
catch(...) {...} // 可以捕获任何类型的异常

在程序中,我们可以用 throw 保留字来抛出一个异常:throw1

在某个 try 语句块里执行过 throw 语句,它后面的所有语句(截止到这个 try 语句块末尾)将永远不会被执行。

与使用一个条件语句或 return 语句相比,采用异常处理机制的好处就是它可以把程序的正常功能合逻辑与出错处理部分清晰地划分开而不是让它们混在一起。

如何让函数抛出异常

可以在定义一个函数时明确地表明你想让它抛出一个异常,为了表明你想让它抛出哪种类型的异常,可以使用如下所示语法:

1
type functionName(arguments) throw(type);

如果没有使用这种语法来定义函数,就意味着函数可以抛出任意类型的异常。

注:有些编译器不支持这种语法,则可以省略 throw(type) 部分。

下面看一个抛出异常的实例:

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
#include <iostream>
#include <cassert>

unsigned long returnFactorial(unsigned short num) throw (const char *);

int main() {
unsigned short num = 0;

std::cout << "请输入一个整数:";
while ( !(std::cin >> num) || (num < 1) ) {
std::cin.clear(); // 清除状态
std::cin.ignore(100, '\n'); // 清除缓冲区
std::cout << "请输入一个整数:";
}
std::cin.ignore(100, '\n');

try {
unsigned long factorial = returnFactorial(num);
std::cout << num << "的阶乘是:" << factorial;
} catch (const char *e) {
std::cout << e;
}

return 0;
}

unsigned long returnFactorial(unsigned short num) throw (const char *) {
unsigned long sum = 1;
unsigned long max = ULONG_MAX;

for (int i = 1; i <= num; i++) {
sum *= i;
max /= i;
}

if (max < 1) {
throw "悲催。。。该基数太大了,无法再该计算机中求出阶乘值。\n";
} else {
return sum;
}
}

TIPS

使用异常的基本原则是:

  • 应该只用它们来处理确实可能不正常的情况。
  • 在构造器合析构器里不应该使用异常。稍有不慎将导致严重的错误。

如果 try 语句块无法找到一个与之匹配的 catch 语句块,它抛出的异常将中止程序的执行。

在 C++ 标准库里有一个名为 exception 的文件,该文件声明了一个 exception 的基类。可以用这个基类来创建个人的子类以管理异常。

有经验的程序员常常这么做,而如此抛出合捕获的是 exception 类或其子类的对象。

如果你打算使用对象作为异常,请记住这样一个原则:以”值传递“方式抛出对象,以”引用传递“方式捕获对象。

10. 动态内存分配

动态内存由一些没有名字、只有地址的内存块构成,那些内存块是在程序运行期间动态分配的。它们来自一个由标准 C++ 库替你管理的“内存池”。

如果没有足够的可用内存空间?

—那么 new 语句将会抛出 std::bad_alloc 异常!

注意: 用完内存块之后,应该 用 delete 语句 把它还给内存池。另外作为一种附加的保险措施,在释放了内存块之后还应该把与之关联的 指针设置为 NULL

如果试图对一个 NULL 指针进行解引用,将在运行时被检测到并将导致程序中止执行。

静态内存这个术语与 C++ 保留字 static 没有任何关系。静态内存意思是指内存块的长度在程序编译时被设定为一个固定的值,而这个值在程序运行时是无法改变的。

new 语句返回的内存块很可能充满“垃圾”数据,我们通常先往里面写一些东西覆盖,再访问它们,或者在类直接写一个构造器来初始化。

使用动态内存时,最重要的原则就是每条 new 语句都必须有一条与之配对的 delete 语句,没有配对的 delete 语句或者有两个配对的 delete 语句都属于变成漏洞(尤其是前者,将导致内存泄漏)。

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
47
48
49
50
51
52
#include <iostream>
#include <string>

class Company {
public:
Company(std::string theName);
virtual void printInfo();

protected:
std::string name;
};

class TechCompany : public Company {
public:
TechCompany(std::string theName, std::string product);
virtual void printInfo();

private:
std::string product;
};

Company::Company(std::string theName) {
name = theName;
}

void Company::printInfo() {
std::cout << "这个公司的名字叫:" << name << "。\n";
}

TechCompany::TechCompany(std::string theName, std::string product) : Company(theName) {
this->product = product;
}

void TechCompany::printInfo() {
std::cout << name << "公司大量生产了 " << product << " 这款产品!\n";
}

int main() {
Company *company = new Company("Huawei"); // 向内存池申请内存
company->printInfo();

delete company; // 释放内存
company = NULL; // 并将指针置为 NULL

company = new TechCompany("Huawei", "Mate");
company->printInfo();

delete company;
company = NULL;

return 0;
}

delete 语句只释放给定指针变量指向的内存块,不影响这个指针。在执行 delete 语句之后,那个内存块被释放了,但指针变量还依然健在。

11. 动态数组

新建一个动态数组:

1
2
3
4
5
6
int *x = new int[10]; // 动态分配 10 个int空间
x[1] = 5; // 可以像使用数组那样使用指针变量 x

// 也可以使用一个变量来声明该数组保存的元素个数
int count = 10;
int *x = new int[count];

删除一个动态数组:

删除一个动态数组要比删除其他动态数据类型稍微复杂一些。因为用来保存数组地址的变量只是一个简单的指针(首地址),所以需要明确地告诉编译器它应该删除一个数组。具体地做法是:在 delete 保留字地后面加上一对方括号 delete []x;

下面举例:

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
#include <iostream>
#include <string>

int main() {
unsigned int count = 0;

std::cout << "请输入数组的元素个数:\n";
std::cin >> count;

int *x = new int[count];

for (int i = 0; i < count; i++) {
x[i] = i;
}

for (int i = 0; i < count; i++) {
std::cout << "x[" << i
<< "]的值是:" << x[i] << "\n";
}

delete []x; // 释放空间
x = NULL; // 指针置为 NULL

return 0;
}

12. 从函数或者方法返回内存

直接从函数或类方法返回不能返回临时变量的地址,因为随着函数的调用结束,临时变量的内存会被释放。

而动态分配的空间是在堆中申请的,函数执行结束后还不会被释放(需要 delete 来释放)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

int *newInt(int value); // 指针函数

int main() {
int *x = newInt(20); // 返回动态申请的空间

std::cout << *x;
delete x;
x = NULL;

return 0;
}

int *newInt(int value) {
int *myInt = new int;
*myInt = value;

return myInt;
}
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
#include <iostream>

void swap(int *x, int *y);

int main() {
int a, b;
a = 3;
b = 5;

swap(&a, &b);

std::cout << "a = " << a << "\n";
std::cout << "b = " << b << "\n";

return 0;
}

void swap(int *x, int *y) {
#if 0 // 又是一个小技巧
int temp; /* 由于 C++ 不支持注释的嵌套,所以可以通过 #if 0 来注释该部分内容 */
temp = *x;
*x = *y;
*y = temp;
#endif

*x ^= *y;
*y ^= *x;
*x ^= *y;
}

13. 副本构造器

可以把一个对象赋值给一个类型与之相同的变量。编译器将生成必要的代码把“源”对象各属性的值分别赋值给“目标”对象的对应成员。这种赋值行为称之为逐位赋值(bitwise copy)。这种行为在绝大多数场合没有问题,但是如果某些成员变量是指针的话,问题就来了:对象成员进行逐位复制的结果是你将拥有两个一摸一样的实例,而这两个副本里的同名指针会指向相同的地址!当删除其中一个对象时,它包含的指针也将被删除,但另一个副本若还在引用这个指针就会出问题。

为什么不同时将对象及其副本的指针都删除呢?

由于 CPU 本身是逐条指令执行的,那么就总会有各先后顺序,当试图第二次释放同一块内存,就会导致程序崩溃。

重载赋值操作符可以解决这个问题

C++ 几乎所有的操作符都可以重载,而赋值操作符“=”也恰好可以重载。将所有指针部分重新申请内存,再赋值。

1
MyClass &operator = (const MyClass &rhs); // 这个方法是输入参数是一个 MyClass 类型的、不可改变的引用。

因为这里使用的参数是一个引用,所以编译器在传递输入参数时就不会再为它另外创建一个副本(否则可能导致无限递归)。这里只需要读入这个输入参数,所以用 const 确保不会改变其值。

还有一个小细节是,该方法的返回值是一个引用,该引用指向 MyClass 类的对象。或许有时觉得返回值没有必要,但是这样确实是一个好习惯!方便我们把一组赋值语句串联起来,如: a = b = c; (赋值顺序从右往左)。

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
47
48
49
50
51
52
53
54
#include <iostream>
#include <string>

class MyClass {
public:
MyClass(int *p);
~MyClass();

MyClass &operator = (const MyClass &rhs);
void print();

private:
int *ptr;

};

MyClass::MyClass(int *p) {
ptr = p;
}

MyClass::~MyClass() {
delete ptr;
}

MyClass &MyClass::operator = (const MyClass &rhs) {
if (this != &rhs) {
delete ptr;

ptr = new int;
*ptr = *rhs.ptr;
} else { // 如果传入的是同一个指针
std::cout << "赋值号两边是同一个对象,不做处理!\n";
}
return *this;
}

void MyClass::print() {
std::cout << *ptr << std::endl;
}

int main() {
MyClass obj1(new int(1));
MyClass obj2(new int(2));

obj1.print();
obj2.print();

obj2 = obj1;

obj1.print();
obj2.print();

return 0;
}

可以看到赋值成功。

再看一个例子:

1
2
MyClass obj1(new int(1));
MyClass obj2 = obj1;

这里在创建第二个对象的同时赋值,与前面创建好两个对象再赋值不同。这种情况下,编译器将在 MyClass 类中寻找一个副本构造器(copy constructor),如果找不到,它会自行创建一个。这时,即使我们对赋值操作符进行了重载,由编译器创建的副本构造器仍以“逐位复制”方式把 obj1 赋值给 obj2 。

想要避免这种隐患,还需要亲自定义一个副本构造器,而不是让系统帮我们生成。

1
MyClass(const MyClass &rhs);

这个构造器需要一个固定不变(const)的 MyClass 类型的引用作为输入参数,就像赋值操作符那样。因为他是一个构造器,所以不需要返回类型。

下面来看一个创建副本构造器的例子:

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#include <iostream>
#include <string>

class MyClass {
public:
MyClass(int *p);
MyClass(const MyClass &rhs); // 自己创建一个副本构造器
~MyClass();

MyClass &operator = (const MyClass &rhs);
void print();

private:
int *ptr;

};

MyClass::MyClass(int *p) {
std::cout << "进入主构造器\n";
ptr = p;
std::cout << "离开主构造器\n";
}

MyClass::MyClass(const MyClass &rhs) {
std::cout << "进入副本构造器\n";
// 此处副本构造器中的 = 已经被重载了,所以会为 this 指针重新申请一片空间拷贝 rhs,不会出现两个指针指向同一片内存的隐患
*this = rhs;
std::cout << "离开副本构造器\n";
}

MyClass::~MyClass() {
std::cout << "进入析构器\n";
delete ptr;
std::cout << "离开析构器\n";
}

MyClass &MyClass::operator = (const MyClass &rhs) {
std::cout << "进入赋值语句重载\n";
if (this != &rhs) {
delete ptr;

ptr = new int;
*ptr = *rhs.ptr;
} else { // 如果传入的是同一个指针
std::cout << "赋值号两边是同一个对象,不做处理!\n";
}
std::cout << "离开赋值语句重载\n";

return *this;
}

void MyClass::print() {
std::cout << *ptr << std::endl;
}

int main() {
MyClass obj1(new int(1));
MyClass obj2(new int(2));

obj2 = obj1;
obj1.print();
obj2.print();

std::cout << "-------------------------\n";

MyClass obj3(new int(3));
MyClass obj4 = obj3;
obj3.print();
obj4.print();

std::cout << "-------------------------\n";

MyClass obj5(new int(5));
obj5 = obj5;
obj5.print();

return 0;
}