C++

  1. 1. 函数的重载
  2. 2. 复杂的数据类型
    1. 2.1. 数组
    2. 2.2. 指针
    3. 2.3. 结构
    4. 2.4. 联合,枚举和类型别名
      1. 2.4.1. 联合(union)
      2. 2.4.2. 枚举(enum)
      3. 2.4.3. 类型别名
  3. 3. 类(class)
  4. 4. 构造器和析构器
    1. 4.1. 构造器
    2. 4.2. 析构器
  5. 5. this指针和类的继承
    1. 5.1. this指针
    2. 5.2. 类的继承
  6. 6. 继承机制中的构造器和析构器
  7. 7. 访问控制
  8. 8. 覆盖方法和重载方法
    1. 8.1. 覆盖方法
    2. 8.2. 重载方法
    3. 8.3. C++成员函数的重载,覆盖,隐藏区别
      1. 8.3.1. 重载与覆盖
  9. 9. 友元关系
  10. 10. 静态属性和静态方法
    1. 10.1. static的作用
    2. 10.2. C++内存分配方式
      1. 10.2.1. static 用来控制变量的存储方式和可见性
        1. 10.2.1.1. static 的内部机制
        2. 10.2.1.2. static 的优势
    3. 10.3. 再论this指针
  11. 11. 虚方法(virtual method)
    1. 11.1. 总结虚方法
  12. 12. 抽象方法(abstract method)
    1. 12.1. 多态性
    2. 12.2. 析构函数解析
    3. 12.3. C++多态实现原理
  13. 13. 纯虚函数和抽象类以及虚析构函数,delete使用
    1. 13.1. 纯虚函数和抽象类
    2. 13.2. 虚析构函数
    3. 13.3. delete运算符
      1. 13.3.1. new和delete运算符的使用
  14. 14. 运算符重载
    1. 14.1. 重载运算符的规则
    2. 14.2. 运算符重载函数作为类友元函数
    3. 14.3. 操作符重载的函数原型列表
  15. 15. C++流的基本概念
  16. 16. 多继承(multiple inheritance)
  17. 17. 虚继承(virtual inheritace)
  18. 18. 错误处理和调试
    1. 18.1. 编译时错误
    2. 18.2. 运行时错误
    3. 18.3. 让函数返回错误代码
  19. 19. assert函数和捕获异常
    1. 19.1. assert函数
    2. 19.2. 捕获异常
      1. 19.2.1. 让函数抛出异常
  20. 20. 动态内存管理
    1. 20.1. 静态内存
    2. 20.2. 动态内存
      1. 20.2.1. NULL指针
  21. 21. 动态数组
  22. 22. 从函数或方法返回内存
  23. 23. 副本构造器
  24. 24. 高级强制类型转换
  25. 25. 避免内存泄露
  26. 26. 命名空间和模块化编程
  27. 27. 链接和作用域
  28. 28. 函数模版
  29. 29. 类模版
  30. 30. 内存模版
  31. 31. 容器和算法

去年学习了面向过程的C语言,实际写算法等程序中,面向对象的C还是非常具有优势。参考b站上小甲鱼的C++快速入门系列教程来记录自己的C学习过程,与C语言相同的知识将不再记述。

函数的重载

所谓函数的重载(overloading)的实质就是用同样的名字再定义一个有着不同参数但有着相同用途的函数。注意:可以是参数个数上的不同,也可以是参数数据类型上的不同。

函数名虽然相同,但传入的类型不同,根据输入的类型的不同,编译器会调用不同的函数。

复杂的数据类型

数组

在C语言中,字符串被实际存储在一个字符数组中。在C中我们也可以用同样的方法实现,但C提供了更好的 std::string 类型

指针

首先要明确一件事:程序是在硬盘上以文件的形式存在,但是它们的运行却是在计算机的内存里发生的!

结构

C++对于一个结构所能包括的变量的格式是没有限制的,那些变量通常我们称为该结构的成员,它们可以是任意一种合法的数据类型。

联合,枚举和类型别名

联合(union)

联合与结构有很多相似之处,联合也可以容纳多种不同类型的值,但是它每次只能储存这些值值的某一个。

1
2
3
4
5
6
7
8
union password
{
unsigned long birthday;
unsigned short ssn;
char* pet;
};

password password_1;

再接下来,我们可以像对结构成员进行赋值那样对联合里的成员进行赋值,使用同样的语法:

1
password_1.birthday = 19881201;

上述语句是将值19881201存入password_1联合的birthday里面。如果我们在执行下面的语句:

1
password_1.pet = "Xxxx";

这个联合将把"Xxxx"存入password_1联合的pet成员,并丢弃birthday成员里的值。

枚举(enum)

枚举类型用来创建一个可取值的列表:

1
enum weekdays{Monday, Tuesday, Wednesday, Thursday, Friday};

定义一个枚举类型之后,我们就可以像下面这样创建该类型的变量:

1
weekdays today;

然后我们像下面的方式对它进行赋值:

1
today Thursday;
  • 注意:这里不需要使用引号,因为枚举值不是字符串。
  • 编译器会按照各个枚举值在定义时出现的先后顺序把它们与0~n-1的整数分别关联起来。(n是枚举值的总个数)
  • 使用枚举类型好处有两个:
    • 它们对变量的可取值加以限制
    • 它们可以用作switch条件语句的case标号(因为字符串是不能作为标号用的)

类型别名

下面介绍Typedef保留字,使用它可以为一个类型定义创建一个别名。

例如,我们不喜欢使用int*来创建指针,可以像下面这样定义一个类型别名:

1
typedef int* intPointer;

在此之后,我们就可以像下面这样来定义整型指针了:

1
intPointer myPointer;

类(class)

1
2
3
4
5
6
7
class Car{
public:
std::string color;
std::string engine;
float gas_tank;
unsigned int wheel;
};

刚刚声明了一辆车的简单属性,为了让他跑起来,现在应该为类定义一些方法,也就是定义一些函数。

给类添加方法:

  • 先在类的声明里创建一个方法的原型
  • 稍后再实现这个方法
1
2
3
4
5
6
7
8
9
10
class Car{
public:
std::string color;
std::string engine;
float gas_tank;
unsigned int wheel;

void fillTank(float liter);
void running(void);
};

现在Car类有了一个名为fillTank的方法,它只有一个参数,不需要任何返回值。但是我们只对它的原型(声明),想要使用它,还需要对这个函数进行正式的定义。方法的定义通常安排在类声明的后面:

1
2
3
void Car::fillTank(float liter){
gas_tank += liter;
}

两个冒号叫做作用域解析符,说明这个fillTank函数是输入Car这个类里面的。也就是作用是告诉编译器这个方法存在于何处,或者说是属于哪一个类。

这里并不提倡用using namespace std这种偷懒的做法,因为在C++中,命名有作用域的区分,如果用这个的话,以下的命名都是属于std的作用域,那么它的作用域就没有起到什么功能了。

事实上std::cout所引用的是std里定义的cout,而std::string数据类型其实也是一个对象。

C++允许在类中声明常量,但不允许对它进行赋值,解决方法就是创建一个静态常量

1
2
3
4
class Car{
public:
static const float FULL_GAS = 85;
}

构造器和析构器

构造器

构造器和通常方法的主要区别:

  • 构造器的名字必须和它所在的类的名字一样
  • 系统在创建某个类的实例时会第一时间自动调用这个类的构造器
  • 构造器永远不会返回任何值

创建构造器,需要先把它的声明添加到类里:

1
2
3
class Car{
Car(void);
};

注意大小写于类名保持一致,在结束声明之后开始定义构造器本身:

1
2
3
4
5
6
7
// 不用写 void Car::Car(void)
Car::Car(void){
color = "WHITE";
engine = "V8";
wheel = 4;
gas_tank = FULL_GAS;
}

构造对象数组:数组可以是任何一种数据类型,当然也包括对象。

1
2
3
4
Car mycar[10];

// x代表着给定数组元素的下标
mycar[x].running;

注意:每个类至少有一个构造器,如果你没有在类里给定一个构造器,编译器就会使用如下语法替你定义一个:ClassName::ClassName(){}

这个是没有代码内容的空构造器,除此,编译器还会替你创建一个副本构造器(CopyConstructor)。

一种常见的做法是在创建对象的同时做一些事情(构造器背后搞鬼),在对象创建出来之后用另一个方法做同样或者不同的事情。

1
2
Car mycar;
mycar.setColor("Yellow");

析构器

在创建对象时,系统都会自动调用一个特殊的方法,即构造器。相应的,在销毁一个对象时,系统也应该会调用另一个特殊方法达到相应的效果。这就是析构器。

一般来说,构造器用来完成事先的初始化和准备工作(申请分配内存),析构器用来完成时候所必须的清理工作(清理内存)。

构造器和析构器两者相辅相成,有许多共同之处。首先,析构器有着和构造器/类一样的名字,只是前面多了一个波浪线"~"前缀。

1
2
3
4
class Car{
Car(void); // 构造器
~Car(); // 析构器
};

析构器也永远不返回任何值。另外析构器是不带参数的。所以析构器的声明永远是如下格式:~ClassName();。

在刚刚的例子中析构器可有可无。但是在比较复杂的类里,析构器往往至关重要(可能引起内存泄漏)。

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

class StoreQuote{
public:
std::string quote, speaker;
std::ofstream fileOutput;

StoreQuote();
~StoreQuote();

void inputQuote();
void inputSpeaker();
bool write();
};

StoreQuote::StoreQuote(){
fileOutput.open("test.txt", std::ios::app);
}

StoreQuote::~StoreQuote(){
fileOutput.close();
}

void StoreQuote::inputQuote(){
std::getline(std::cin, quote);
}

void StoreQuote::inputSpeaker(){
std::getline(std::cin, speaker);
}

bool StoreQuote::write(){
if (fileOutput.is_open())
{
fileOutput << quote << ":" << speaker << "\n";
return true;
}
else
{
return false;
}
}

int main(int argc, char const *argv[])
{
StoreQuote quote;

std::cout << "请输入一句名言: \n";
quote.inputQuote();

std::cout << "请输入作者: \n";
quote.inputSpeaker();

if (quote.write())
{
std::cout << "成功写入文件";
}
else
{
std::cout << "写入文件失败";
return 1;
}

return 0;
}

this指针和类的继承

this指针

在对象的世界里,有一个特殊的指针,它叫做this。我们从在没有见过它,但是它却从来都存在。我们通过一个典型的例子来认识它:

1
2
3
4
5
6
7
8
class Human{
char fishc;
Human(char fishc);
};

Human::Human(char fishc){
fishc = fishc;
}

在"fishc = fishc"之前,所有的语法都没有任何问题:

  • Human()构造器有一个名为fishc的参数
  • 虽然它与Human类里面的属性同名,但却是不相干的两样东西,所以并没有错。
  • 可是,问题是怎样才能让构造器知道哪个是参数,哪个是属性呢?

这个时候就用到this指针了

1
2
// this -> 属性 = 参数
this -> fishc = fishc

this指针的意思是指向当前的类生成的对象。

现在编译器就懂了,赋值操作符的左边将被解释为当前对象的fishc属性,右边将被解释为构造器的传入来的fishc参数。注意:使用this指针的基本原则是:如果代码不存在两义性隐患,就不必使用this指针!

类的继承

继承是面向对象编程技术的一个核心概念,它使传统的软件开发模式发生类革命性的变化。

继承机制使得程序员可以创建一个类的堆叠层次结构,每个子类均将继承在它的基类里定义的方法和属性。

假设有一只乌龟和一只猪,它们都有一些共同的特征:吃饭睡觉等等,我们会说乌龟和猪都是动物,我们发觉它们都是动物的子类。那么我们就需要编写一个Animal类作为Turtle类和Pig类的基类。

  • 基类是可以派生出其他的类,也称为父类或者超类。比如这里的Animal类就是基类。
  • 子类是从基类派生出来的类。比如这里的Turtle类和Pig类都是子类。

那么Animal类就拥有类Turtle类和Pig类的共同特征:吃饭,睡觉,流口水。这里我们把这些动作都抽象化为方法。

  • eat(), sleep(), drool();

而swim()方法在Turtle类里实现,而climb()方法在Pig类里实现。

动物都有嘴巴,而嘴巴是名词不是动作,所以要翻译成类的属性,而不能翻译成类的方法。我们将mouth转变为Animal类的一个成员变量(属性)。

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

class Animal{
public:
std::string mouth;

void eat();
void sleep();
void drool();
};

class Pig : public Animal{
public:
void climb();
};

class Turtle : public Animal{
public:
void swim();
};

void Animal::eat(){
std::cout << "I'm eatting!" << std::endl;
}

void Animal::sleep(){
std::cout << "I'm sleeping!" << std::endl;
}

void Animal::drool(){
std::cout << "I'm drooling!" << std::endl;
}

void Pig::climb(){
std::cout << "I'm a pig. I can climb!" << std::endl;
}

void Turtle::swim(){
std::cout << "I'm a turtle. I can swim!" << std::endl;
}

int main(int argc, char const *argv[])
{
Pig pig;
Turtle turtle;

pig.eat();
turtle.eat();

pig.climb();
turtle.swim();

return 0;
}
1
2
3
4
5
6
g++ -std=c++11 inherit.cpp && ./a.out

I'm eatting!
I'm eatting!
I'm a pig. I can climb!
I'm a turtle. I can swim!

继承机制中的构造器和析构器

访问控制

级别 允许谁来访问
public 任何代码
protected 这个类本身和它的子类
private 只有这个类本身

利用访问级别来保护类里的方法和属性很简单,只要在类里的某个地方写出一个访问级别并在其后面加一个冒号,从那个地方开始往后的所有方法和属性都将收到相应的保护,直到遇到下一个访问级别或者到达这个类的末尾为止。

  • 关于从基类继承来的方法和属性的保护:
    • class Pig : public Animal{…}
  • C++不仅允许你对在基类里定义的方法和属性实施访问控制,还允许你控制子类可以访问基类里的哪些方法和属性。
  • public
    • 是在告诉编译器:继承的方法和属性的访问级别不发生任何改变,即public仍可以被所有代码访问,protected只能由基类的子类访问,private则只能由基类本身访问
  • protected
    • 把基类的访问级别改为protected,如果原来是public的话,这将使得这个子类外部的代码无法通过子类去访问基类中的public
  • private
    • 是在告诉编译器从基类继承来的每一个成员都当成private来对待,这意味着只有这个子类可以使用它从基类继承来的元素

一般只用public而已。

覆盖方法和重载方法

覆盖方法

当我们需要在基类里提供一个通用的函数,但在它的某个子类里需要修改这个方法的实现,在C++里,在覆盖(overriding)就可以做到。

回到之前的例子,动物都能吃,但是不同的动物有不同的吃法,吃什么也不同。C++可以让我们容易实现这种既有共同特征又需要在不同的类里有不同实现的方法。我们需要做的是在类里重新声明这个方法,然后再改写一下它的实现代码(就像它是增加的方法那样)就行了。

下面修改例子,为我们的Animal添加eat()方法,并在Pig和Turtle中覆盖。

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

class Animal
{
public:
Animal(std::string theName);
void eat();
void sleep();
void drool();

protected:
std::string name;
};

class Pig : public Animal
{
public:
Pig(std::string theName);
void climb();
void eat(); // 为了覆盖需要在子类里再一次声明再一次实现
};

class Turtle : public Animal
{
public:
Turtle(std::string theName);
void swim();
void eat(); // 为了覆盖需要在子类里再一次声明再一次实现
};

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

void Animal::eat()
{
std::cout << "I'm eatting!" << std::endl;
}

void Animal::sleep()
{
std::cout << "I'm sleeping!" << std::endl;
}

void Animal::drool()
{
std::cout << "I'm drooling!" << std::endl;
}

Pig::Pig(std::string theName) : Animal(theName)
{
}

void Pig::climb()
{
std::cout << "I'm a pig. I can climb!" << std::endl;
}

void Pig::eat()
{
Animal::eat(); // 小技巧!也可以直接调用基类中的eat()
std::cout << name << "eatting fish" << std::endl; // new!
}

Turtle::Turtle(std::string theName) : Animal(theName)
{
}

void Turtle::swim()
{
std::cout << "I'm a turtle. I can swim!" << std::endl;
}

void Turtle::eat()
{
Animal::eat();
std::cout << name << "eatting meat" << std::endl; // new!
}

int main(int argc, char const *argv[])
{
Pig pig("Pig ");
Turtle turtle("Turtle ");

pig.eat();
turtle.eat();

printf("\n");

pig.climb();
turtle.swim();

return 0;
}
1
2
3
4
5
6
7
8
9
g++ -std=c++11 override.cpp && ./a.out

I'm eatting!
Pig eatting fish
I'm eatting!
Turtle eatting meat

I'm a pig. I can climb!
I'm a turtle. I can swim!

重载方法

简化编程互作和提高代码可读性的另一种方法是对方法进行重载。

重载机制使你可以定义多个同名的方法(函数),只是它们的输入参数必须不同(因为编译器是依靠不同的输入参数来区分不同的方法)。

重载并不是一个真正的面向对象特征,它只是可以简化编程互作的捷径,而简化编程互作正是C++的全部追求。

用重载修改eat():

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

class Animal
{
public:
Animal(std::string theName);
void eat();
void eat(int eatCount);
void sleep();
void drool();

protected:
std::string name;
};

class Pig : public Animal
{
public:
Pig(std::string theName);
void climb();
};

class Turtle : public Animal
{
public:
Turtle(std::string theName);
void swim();
};

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

void Animal::eat()
{
std::cout << "I'm eatting!" << std::endl;
}

void Animal::eat(int eatCount)
{
std::cout << "我吃了" << eatCount << "碗馄饨!" << std::endl;
}

void Animal::sleep()
{
std::cout << "I'm sleeping!" << std::endl;
}

void Animal::drool()
{
std::cout << "I'm drooling!" << std::endl;
}

Pig::Pig(std::string theName) : Animal(theName)
{
}

void Pig::climb()
{
std::cout << "I'm a pig. I can climb!" << std::endl;
}

Turtle::Turtle(std::string theName) : Animal(theName)
{
}

void Turtle::swim()
{
std::cout << "I'm a turtle. I can swim!" << std::endl;
}

int main(int argc, char const *argv[])
{
Pig pig("Pig ");
Turtle turtle("Turtle ");

pig.eat();
turtle.eat();

printf("\n");

pig.eat(15);

printf("\n");

pig.climb();
turtle.swim();

return 0;
}
1
2
3
4
5
6
7
8
9
g++ -std=c++11 overwrite.cpp && ./a.out

I'm eatting!
I'm eatting!

我吃了15碗馄饨!

I'm a pig. I can climb!
I'm a turtle. I can swim!

这里eat()方法有两个,加了参数就会调用有参数的eat(),而不是调用没加参数的eat()。这样实现了重载,同名函数参数不同。

注意:在对方法进行覆盖(注意区分覆盖和重载)时,一定要看仔细,因为只要声明的输入参数和返回值与原来不一致,你编写出来的就将是一个重载方法而不是一个覆盖方法。

C++成员函数的重载,覆盖,隐藏区别

重载与覆盖

成员函数被重载的特征:

  1. 相同的范围(在同一个类中)
  2. 函数的名字相同
  3. 参数不同
  4. virtual关键字可有可无

覆盖是指派生类函数覆盖基类函数,特征是:

  1. 不同的范围(分别位于派生类与基类)
  2. 函数名字相同
  3. 参数相同
  4. 基类函数必须有virtual关键字

友元关系

在编程中我们通过public,protected,private这些访问级别可以让程序员控制谁有权限使用某个类中的某个方法和属性。这个强大的方案可以把代码的实现细节掩藏起来,不让没有相应权限的其他代码访问到。

可是在某些场合,一个完全无关的类由于某些特殊原因需要访问到某个protected成员,甚至某个private成员,那该怎么办呢?

为此,C++的发明者提供了一个解决方案:友元关系

友元关系是类之间的一种特殊关系,这种关系不仅允许友元类访问对方的public方法和属性,还允许友元访问对方的protectedprivate方法和属性。

  • 声明友元关系的语法:在类声明里的某个地方加上friend class **就行了
  • 注意,这条语句可以放在任何地方,放在public,protected,private段落里都可以

下面看一个例子:

  • Lovers类有两个子类:Boyfriend类和Girlfriend
  • Lovers类有情人应有的方法:kiss(),ask()等
  • 另外增加第三者Others类作为路人甲代表,使得Others类可以图谋不轨想要kiss()Girlfriend类的对象
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
#include <iostream>
#include <string>

class Lovers
{
public:
Lovers(std::string theName);
void kiss(Lovers *lover);
void ask(Lovers *lover, std::string theName);

protected:
std::string name;
friend class Others; // 祸根,交友不慎
};

class Boyfriend : public Lovers
{
public:
Boyfriend(std::string theName);
};

class Girlfriend : public Lovers
{
public:
Girlfriend(std::string theName);
};

class Others
{
public:
Others(std::string theName);
void kiss(Lovers *lover);

protected:
std::string name;
};

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

void Lovers::kiss(Lovers *lover)
{
std::cout << name << "亲亲我们家的" << lover->name << std::endl;
}

void Lovers::ask(Lovers *lover, std::string something)
{
std::cout << "宝贝儿" << lover->name << "帮我" << something << std::endl;
}

Boyfriend::Boyfriend(std::string theName) : Lovers(theName)
{
}

Girlfriend::Girlfriend(std::string theName) : Lovers(theName)
{
}

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

void Others::kiss(Lovers *lover)
{
std::cout << name << "亲一下" << lover->name << std::endl;
}

int main(int argc, char const *argv[])
{
Boyfriend boyfriend("A君");
Girlfriend grilfriend("B妞");

Others others("路人甲");

grilfriend.kiss(&boyfriend);
grilfriend.ask(&boyfriend, "洗衣服啦");

std::cout << "路人甲登场" << std::endl;

others.kiss(&grilfriend);

return 0;
}
1
2
3
4
5
6
g++ -std=c++11 friend.cpp && ./a.out

B妞亲亲我们家的A君
宝贝儿A君帮我洗衣服啦
路人甲登场
路人甲亲一下B妞

这个例子中,Others类的kiss()方法中调用了name,而这个name是属于Lover类中的protectedOthers类不是Lovers的子类,理论上是应该访问不到的。但是,利用了友元关系,从而成功访问到了。

静态属性和静态方法

  • 面向对象技术编程的一个重要特征是: 用一个对象把数据和对数据处理的方法封装在一起
  • 那么如果我们所需要的功能或数据不属于某个特性的对象,而是属于整个类的,该怎么办?

现在我们假设需要统计一个计数器变量,用来计算有多少只活的动物,每出生一只就加1,死亡一只就减1。

首先想到的是创建一个全局变量来充当这个计数器,但是这么做的缺点是程序中任何代码都可以修改这个计数器,稍一不小心就会在程序中留下一个难以修改的漏洞。所以坚决不建议在非必要的时候声明全局变量!

我们需要的是一个只在创建或删除对象的时候才允许访问的计数器!这个问题必须使用C++的静态属性和静态函数才能完美地得到解决!

C++允许我们把一个或者多个成员声明为属于某个类,而不是仅属于该类的对象

这么做的好处是程序员可以在没有创建任何对象的情况下调用有关的方法。另一个好处是能够让有关的数据仍在该类的所有对象空间共享。

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

static的作用

static的作用是隐藏(static函数和static变量均可)

当我们同时编译多个文件时,所有未加static前缀的全局变量和函数都具有全局可见性。

例: 同时编译两个源文件,一个是a.c,另一个是main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//a.c
char a = 'A'; // global variable
void msg()
{
printf("Hello\n");
}

//main.c
int main()
{
extern char a; // extern variable must be declared before use
printf("%c ", a);
(void)msg();
return 0;
}
1
2
3
./a.out

A Hello

为什么在a.c中定义的全局变量a和函数msg能在main.c中使用?前面说过,所有未加static前缀的全局变量和函数都具有全局可见性,其它的源文件也能访问。此例中,a是全局变量,msg是函数,并且都没有加static前缀,因此对于另外的源文件main.c是可见的。

如果加了static,就会对其它源文件隐藏。例如在a和msg的定义前加上static,main.c就看不到它们了。利用这一特性可以在不同的文件中定义同名函数和同名变量,而不必担心命名冲突。static可以用作函数和变量的前缀,对于函数来讲,static的作用仅限于隐藏.

static的作用是保持变量内容的持久(static变量中的记忆功能和全局生存期)

存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化。共有两种变量存储在静态存储区:全局变量和static变量,只不过和全局变量比起来,static可以控制变量的可见范围,说到底static还是用来隐藏的。虽然这种用法不常见

PS:如果作为static局部变量在函数内定义,它的生存期为整个源程序,但是其作用域仍与自动变量相同,只能在定义该变量的函数内使用该变量。退出该函数后, 尽管该变量还继续存在,但不能使用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

int fun()
{
static int count = 10; //在第一次进入这个函数的时候,变量a被初始化为10!并接着自减1,以后每次进入该函数,a
return count--; //就不会被再次初始化了,仅进行自减1的操作;在static发明前,要达到同样的功能,则只能使用全局变量:
}

int count = 1;

int main(void)
{
printf("global\t\tlocal static\n");
for(; count <= 10; ++count)
printf("%d\t\t%d\n", count, fun());
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
./a.out

global local static

1 10

2 9

3 8

4 7

5 6

6 5

7 4

8 3

9 2

10 1

基于以上两点可以得出一个结论:把局部变量改变为静态变量后是改变了它的存储方式即改变了它的生存期。把全局变量改变为静态变量后是改变了它的作用域, 限制了它的使用范围。因此static 这个说明符在不同的地方所起的作用是不同的。

static的作用默认初始化为0(static变量)

其实全局变量也具备这一属性,因为全局变量也存储在静态数据区。在静态数据区,内存中所有的字节默认值都是0x00,某些时候这一特点可以减少程序员的工作量。比如初始化一个稀疏矩阵,我们可以一个一个地把所有元素都置0,然后把不是0的几个元素赋值。如果定义成静态的,就省去了一开始置0的操作。再比如要把一个字符数组当字符串来用,但又觉得每次在字符数组末尾加‘\0’;太麻烦。如果把字符串定义成静态的,就省去了这个麻烦,因为那里本来就是‘\0’;不妨做个小实验验证一下。

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int a;

int main()
{
int i;
static char str[10];
printf("integer: %d; string: (begin)%s(end)", a, str);
return 0;
}
1
2
3
./a.out

integer: 0; string: (begin) (end)

首先static的最主要功能是隐藏,其次因为static变量存放在静态存储区,所以它具备持久性和默认值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
77
#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.因为它在静态存储区,把这个变量初始化为0

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

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

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

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

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

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

int main(int argc, char const *argv[])
{
Dog dog("Jerry");
Cat cat("Tom");

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

{
Dog dog("Jerry_2");
Cat cat("Tom_2");

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

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

return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
g++ -std=c++11 static.cpp && ./a.out

一只宠物出生了,名字叫做: Jerry
一只宠物出生了,名字叫做: Tom

已经诞生了2只宠物!

一只宠物出生了,名字叫做: Jerry_2
一只宠物出生了,名字叫做: Tom_2

现在呢,已经诞生了4只宠物!

Tom_2挂掉了
Jerry_2挂掉了

现在还剩下2只宠物!

Tom挂掉了
Jerry挂掉了

这里代码67行到72行的大括号可以认为是一个区域,内部的作用域结束了,系统就自动调用了它们的析构器,清理内存。如果去掉67行和72行的大括号,则4只宠物将在最后挂掉(最后清理内存)。

下面继续探讨静态方法:

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

如果对以上两句话不理解,需要看一下C++内存分配方式详解——堆、栈、自由存储区、全局/静态存储区和常量存储区

C++内存分配方式

  • 栈,就是那些由编译器在需要的时候分配,在不需要的时候自动清除的变量的存储区。里面的变量通常是局部变量、函数参数等。在一个进程中,位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数的调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。
  • 堆,就是那些由 new 分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个 new 就要对应一个 delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。堆可以动态地扩展和收缩。
  • 自由存储区,就是那些由 malloc 等分配的内存块,他和堆是十分相似的,不过它是用 free 来结束自己的生命的。
  • 全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的 C 语言中,全局变量又分为初始化的和未初始化的(初始化的全局变量和静态变量在一块区域,未初始化的全局变量与静态变量在相邻的另一块区域,同时未被初始化的对象存储区可以通过 void* 来访问和操纵,程序结束后由系统自行释放),在 C++ 里面没有这个区分了,他们共同占用同一块内存区。
  • 常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改(当然,你要通过非正当手段也可以修改,而且方法很多)

static 用来控制变量的存储方式和可见性

函数内部定义的变量,在程序执行到它的定义处时,编译器为它在栈上分配空间,函数在栈上分配的空间在此函数执行结束时会释放掉,这样就产生了一个问题: 如果想将函数中此变量的值保存至下一次调用时,如何实现? 最容易想到的方法是定义一个全局的变量,但定义为一个全局变量有许多缺点,最明显的缺点是破坏了此变量的访问范围(使得在此函数中定义的变量,不仅仅受此 函数控制)。需要一个数据对象为整个类而非某个对象服务,同时又力求不破坏类的封装性,即要求此成员隐藏在类的内部,对外不可见。

static 的内部机制

静态数据成员要在程序一开始运行时就必须存在。因为函数在程序运行中被调用,所以静态数据成员不能在任何函数内分配空间和初始化。这样,它的空间分配有三个可能的地方,一是作为类的外部接口的头文件,那里有类声明;二是类定义的内部实现,那里有类的成员函数定义;三是应用程序的 main()函数前的全局数据声明和定义处。

静态数据成员要实际地分配空间,故不能在类的声明中定义(只能声明数据成员)。类声明只声明一个类的“尺寸和规格”,并不进行实际的内存分配,所以在类声明中写成定义是错误的。它也不能在头文件中类声明的外部定义,因为那会造成在多个使用该类的源文件中,对其重复定义。

static 被引入以告知编译器,将变量存储在程序的静态存储区而非栈上空间,静态数据成员按定义出现的先后顺序依次初始化,注意静态成员嵌套时,要保证所嵌套的成员已经初始化了。消除时的顺序是初始化的反顺序。

static 的优势

可以节省内存,因为它是所有对象所公有的,因此,对多个对象来说,静态数据成员只存储一处,供所有对象共用。静态数据成员的值对每个对象都是一样,但它的 值是可以更新的。只要对静态数据成员的值更新一次,保证所有对象存取更新后的相同的值,这样可以提高时间效率。引用静态数据成员时,采用如下格式:

<类名>::<静态成员名>

如果静态数据成员的访问权限允许的话(即 public 的成员),可在程序中,按上述格式来引用静态数据成员。

注意:

  • 类的静态成员函数是属于整个类而非类的对象,所以它没有this指针,这就导致了它仅能访问类的静态数据和静态成员函数
  • 不能将静态成员函数定义为虚函数
  • 由于静态成员声明于类中,操作于其外,所以对其取地址操作,就多少有些特殊,变量地址是指向其数据类型的指针,函数地址类型是一个“nonmember 函数指针”
  • 由于静态成员函数没有 this 指针,所以就差不多等同于 nonmember 函数,结果就产生了一个意想不到的好处:成为一个 callback 函数,使得我们得以将 c++ 和 c-based x window 系统结合,同时也成功的应用于线程函数身上
  • static 并没有增加程序的时空开销,相反她还缩短了子类对父类静态成员的访问时间,节省了子类的内存空间
  • 静态数据成员在<定义或说明>时前面加关键字 static
  • 静态数据成员是静态存储的,所以必须对它进行初始化
  • 静态成员初始化与一般数据成员初始化不同
    • 初始化在类体外进行,而前面不加 static,以免与一般静态变量或对象相混淆
    • 初始化时不加该成员的访问权限控制符 private、public
    • 初始化时使用作用域运算符来标明它所属类
    • 所以我们得出静态数据成员初始化的格式: <数据类型><类名>::<静态数据成员名>=<值>
  • 为了防止父类的影响,可以在子类定义一个与父类相同的静态变量,以屏蔽父类的影响。这里有一点需要注意:我们说静态成员为父类和子类共享,但我们有重复定义了静态成员,这会不会引起错误呢?不会,我们的编译器采用了一种绝妙的手法:name-mangling 用以生成唯一的标志

再论this指针

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
30
31
32
33
34
35
#include <iostream>

class Point
{
private:
int x, y;

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;
}
};

int main(int argc, char const *argv[])
{
Point point1(10, 10);

point1.MovePoint(2, 2);
point1.print();

return 0;
}
1
2
3
g++ -std=c++11 this.cpp && ./a.out

x=2y=2
  • 当对象point1调用MovePoint(2, 2)函数时,即将point1对象的地址传递给了this指针
  • 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的数据成员被调用并更新了值

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

另外需要注意:

  • 在使用静态属性的时候,千万不要忘记为它们分配内存。具体方法很简单,只需要在类声明的外部对静态属性做出声明即可
  • 静态方法也可以使用一个普通方法的调用语法来调用,但不建议这样做,那样会跟普通的方法混淆,代码可读性很糟糕
    • 坚持使用: ClassName::methodName();
    • 不要使用: objectName::methodName();

虚方法(virtual method)

首先通过一个引发问题:使用指向对象的指针说起。指针说白了就是一种专门用来保存内存地址的数据类型。以前常用的做法是:创建一个变量,再把这个变量的地址赋值给一个指针。然后就可以用指针去访问这个变量的值了。
事实上,在C/C中,完全可以在没有创建变量的情况下为有关数据分配内存。也就是直接创建一个指针并让它指向新分配的内存块:需要我们认识两个新的C保留字:new和delete

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

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

先看一下之前原始的pet的例子:

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
#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 << "正在吃东西!" << std::endl;
}

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

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

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

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

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

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

void Dog::bark()
{
std::cout << name << "旺!旺!" << std::endl;
}

void Dog::play()
{
Pet::play();
std::cout << name << "正在追赶那只猫!" << std::endl;
}

int main(int argc, char const *argv[])
{
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;
}
1
2
3
4
5
6
7
8
g++ -std=c++11 pet.cpp && ./a.out

加菲正在睡觉!
加菲正在吃东西!
加菲正在玩!
欧迪正在睡觉!
欧迪正在吃东西!
欧迪正在玩!

仔细一看,发现程序与我们预期不符:我们在Cat和Dog类里对play()方法进行了覆盖,但实际上调用的是Pet::play()方法而不是那两个覆盖的版本。为什么呢?

  • 程序之所以有这样奇怪的行为,是因为C的创始人希望用C生成的代码至少和C一样快
  • 所以程序在编译的时候,编译器将检查所有的代码,在如何对某个数据进行处理和可以对该类型的数据进行何种处理之间寻找一个最佳点
  • 正是这一项编译时的检查影响里刚才的程序结果:cat和dog在编译时都是Pet类型的指针,编译器就会认为两个指针调用的play()方法是Pet::play()方法,因为这是执行起来最快的解决方案。

而引发问题的源头就是我们使用来了new在程序运行的时候才为dog和cat分配Dog类型和Cat类型的指针。这些都是它们在运行时才分配的类型,和它们在编译时的类型是不一样的!为了让编译器知道它应该根据这两个指针在运行时的类型而有选择地调用正确的方法(Dog::play()和Cat::play()),我们必须把这些方法声明为虚方法。

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

1
virtual void play();

另外,虚方法是继承的,一旦在基类里把某个方法声明为虚方法,在子类里就不可能再把它声明为一个非虚方法了。这对于设计程序来说是一个好事,因为这可以让程序员无需顾虑一个虚方法会在某个子类里编了一个非虚方法。

使用虚方法可以让上面的Pet程序如预期完成:

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

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

void eat();
void sleep();
virtual void play();

protected:
std::string name;
};

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

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

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

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

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

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

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

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

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

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

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

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

void Dog::bark()
{
std::cout << name << "旺!旺!" << std::endl;
}

void Dog::play()
{
Pet::play();
std::cout << name << "正在追赶那只猫!" << std::endl;
}

int main(int argc, char const *argv[])
{
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;
}
1
2
3
4
5
6
7
8
9
10
g++ -std=c++11 pet2.cpp && ./a.out

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

总结虚方法

  • 虚方法一般声明在基类,或者相对基类
  • 使用new动态分配内存的时候可以用基类的指针声明子类的实例
  • 这个时候在调用方法的时候,如果基类方法前面有virtual,那么编译器不会立即指向基类的方法,而是转而检查子类中是否有这个方法的覆盖
    • 如果有就执行覆盖后的方法,否则执行基类中的方法
  • 如果没有virtual,则直接执行基类中的方法

抽象方法(abstract method)

抽象方法(也可以称为纯虚函数)是面向对象编程技术的另一个核心概念,在设计一个多层次的类继承关系时常会用到。它是一个抽象的接口,例如C语言中的printf函数,我们只需要输入规定的参数,就可以实现输出,但是它内部如何调用显卡如何调用CPU如何运用内存,我们不需要关心,这个printf我们可以认为是一个接口。

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

在声明虚方法的基础上,在原型的末尾加上`=

继续看上次pet的例子,进行改动

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
#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 << "正在吃东西!" << std::endl;
}

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

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

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

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

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

void Dog::bark()
{
std::cout << name << "旺!旺!" << std::endl;
}

void Dog::play()
{
std::cout << name << "正在追赶那只猫!" << std::endl;
}

int main(int argc, char const *argv[])
{
Cat cat("加菲");
Dog dog("欧迪");

cat.sleep();
cat.eat();
cat.play();

dog.sleep();
dog.eat();
dog.play();

return 0;
}
1
2
3
4
5
6
7
8
g++ -std=c++11 pet3.cpp && ./a.out

加菲正在睡觉!
加菲正在吃东西!
加菲玩毛线球!
欧迪正在睡觉!
欧迪正在吃东西!
欧迪正在追赶那只猫!

多态性

  • 多态性是面向对象程序设计的重要特征之一
  • 简单的说,多态性是指用一个函数名定义不同的函数,调用同一个名字的函数,却执行不同的操作,从而实现了"一个接口,多种方法"

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

class ClsBase
{
public:
ClsBase()
{
};
virtual ~ClsBase()
{
};
virtual void doSomething()
{
std::cout << "Do something in class ClsBase!" << std::endl;
};
};

class ClsDerived : public ClsBase
{
public:
ClsDerived()
{
};
~ClsDerived()
{
std::cout << "Output from the destructor of class ClsDerived!" << std::endl;
};
void doSomething()
{
std::cout << "Do something in class ClsDerived!" << std::endl;
};
};

int main()
{
ClsBase *pTest = new ClsDerived;

pTest -> doSomething();

delete pTest;

return 0;
}
1
2
3
4
./a.out

Do something in class ClsDerived!
Output from the destructor of class ClsDerived!
  • 但是,如果我们把ClxBase类的析构函数前的virtual去掉,那输出结果就应该是:
    • Do something in class ClsDerived!
    • 也就是说类ClxDerived的析构函数没有被调用到!!!调用了这个子类却没对这个子类进行析构。这是非常危险的!(参照下一节问题解决部分)
  • 一般下,类的析构函数里面都是释放内存资源,而析构函数不被调用的话就会造成内存泄露
  • 所以,析构器都是虚方法是为了当一个基类的指针删除一个派生类的对象时,派生类的析构函数可以被正确调用
  • 另外,当类里面有虚函数的时候,编译器会给类添加一个虚函数表,里面存放着虚函数指针。

为了节省资源,只有当一个类被用来作为基类的时候,我们才能把析构函数写成虚函数!

如果这个函数根本不用在基类写它的实现,我们就把这个函数写成抽象函数,也称作纯虚函数!

C++多态实现原理

C++的多态性是通过延迟绑定(late binding)技术来实现的。

C++的多态性用一句话概括就是:在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。

这里注意区分一下类的多态性和函数的多态性:
这里的多态性是指类的多态性,函数的多态性是指一个函数被定义成多个不同参数的函数。它们一般被存在头文件中,当你调用这个函数,针对不同的参数,就会调用不同的同名函数。例如,Rectangle(),它的参数可以是两个坐标点(point,point),也可能是4个坐标(x1,y1,x2,y2),这叫函数的多态性与函数的重载。

类的多态性,是指虚函数和延迟绑定来实现的。函数的多态性,是指函数的重载。

一般情况下,(没有涉及virtual函数),当我们用一个指针/引用调用一个函数的时候,被调用的函数是取决于这个指针/引用的类型。即如果这个指针/引用是基类对象的指针/引用就调用基类的方法;如果指针/引用是派生类对象的指针/引用就调用派生类的方法,当然如果派生类中没有此方法,就会向上到基类里面去寻找相应的方法。这些调用是在编译阶段就确定了。
当涉及到多态性的时候,采用了虚函数和动态绑定,此时的调用就不会在编译的时候确定而是在运行时确定。不在单独考虑指针/引用的类型而是看指针/引用的对象的类型来判断函数的调用,根据对象中虚指针指向的虚表中的函数的地址来确定调用哪个函数。

纯虚函数和抽象类以及虚析构函数,delete使用

在上一节的pet例子中,我遇到了未使用虚析构函数时出现内存泄露(当父类指针指向子类指针对象时)的问题,发现了解决方案,以下是原文链接

纯虚函数和抽象类

  • 纯虚函数是一个在基类中说明的虚函数,在基类中没有定义,要求任何派生类都定义自己的版本
  • 纯虚函数为各个派生类提供一个公共接口
  • 纯虚函数的形式:
    • virtual 类型 函数名(参数列表) = 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
class Parent    //抽象类
{
public:
Parent()
{
cout << "Parent construct" << endl;
}

virtual void overrideFunc() = 0; //纯虚函数
};

class Child01:public Parent  //未实现纯虚函数,所以还是一个抽象类,不能被实例化对象,可以被继承
{
public:
Child01()
{
cout << "Child01 construct" << endl;
}
};

class Child02 :public Parent  //未实现纯虚函数,是抽象类,可以被继承实现
{
public:
Child02()
{
cout << "Child02 construct" << endl;
}
};

class ChildSon :public Child01
{
public:
ChildSon()
{
cout << "ChildSon construct" << endl;
}

virtual void overrideFunc()  //实现了纯虚函数,是一个可以实例化对象的类
{
cout << "ChildSon finish" << endl;
}
};

void main()
{
ChildSon c;
system("pause");
}

虚析构函数

注意:构造函数不能是虚函数:建立一个派生类对象时,必须从类层次的根开始,沿着继承路径逐个调用基类的构造函数

问题引出:未使用虚析构函数时会出现内存泄漏(当父类指针指向子类对象时)

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>

using namespace std;

class Parent //抽象类
{
public:
char *name;

public:
Parent(char *n)
{
name = (char *)malloc(strlen(n) + 1);
strcpy(name, n);
cout << "Parent construct" << endl;
}

virtual void getInfo()
{
cout << "parent name:" << this->name << endl;
}

~Parent()
{
cout << "Parent distruct" << endl;
if (this->name)
{
delete this->name;
this->name = NULL;
}
}
};

class Child01 : public Parent
{
public:
char *addr;

public:
Child01(char *n, char *a) : Parent(n)
{
addr = (char *)malloc(strlen(a) + 1);
strcpy(addr, a);
cout << "Child01 construct" << endl;
}

virtual void getInfo()
{
cout << "child01 name:" << this->name << endl;
cout << "child01 addr:" << this->addr << endl;
}

~Child01()
{
cout << "Child01 distruct" << endl;
if (this->name)
{
delete name;
this->name = NULL; //释放后置空,是一个良好的习惯
}
if (this->addr)
{
delete addr;
this->addr = NULL;
}
}
};

int main()
{
Parent *p = new Child01("Liu", "zz");

delete p; //会根据父类指针去调用父类析构函数:回顾前面多态

system("pause");

return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
g++ -std=c++11 child.cpp && ./a.out

child.cpp:71:29: warning: ISO C++11 does not allow conversion from string literal to 'char *' [-Wwritable-strings]
Parent *p = new Child01("Liu", "zz");
^
child.cpp:71:36: warning: ISO C++11 does not allow conversion from string literal to 'char *' [-Wwritable-strings]
Parent *p = new Child01("Liu", "zz");
^
2 warnings generated.
Parent construct
Child01 construct
Parent distruct

发现只调用了父类析构函数,释放了name变量,但是addr变量并没有进行释放,导致了内存泄漏

问题解决:联系前面多态,使用虚析构函数—>会根据虚函数指针找到虚函数表从而调用子类析构函数(而)子类析构时候同构造相反方向去调用基类析构方法

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

using namespace std;

class Parent //抽象类
{
public:
char *name;

public:
Parent(char *n)
{
name = (char *)malloc(strlen(n) + 1);
strcpy(name, n);
cout << "Parent construct" << endl;
}

virtual void getInfo()
{
cout << "parent name:" << this->name << endl;
}

virtual ~Parent() // new
{
cout << "Parent distruct" << endl;
if (this->name)
{
delete this->name;
this->name = NULL;
}
}
};

class Child01 : public Parent
{
public:
char *addr;

public:
Child01(char *n, char *a) : Parent(n)
{
addr = (char *)malloc(strlen(a) + 1);
strcpy(addr, a);
cout << "Child01 construct" << endl;
}

virtual void getInfo()
{
cout << "child01 name:" << this->name << endl;
cout << "child01 addr:" << this->addr << endl;
}

virtual ~Child01() // new
{
cout << "Child01 distruct" << endl;
if (this->name)
{
delete name;
this->name = NULL; //释放后置空,是一个良好的习惯
}
if (this->addr)
{
delete addr;
this->addr = NULL;
}
}
};

void testfunc()
{
Parent *p = new Child01("Liu", "zz");

delete p;
}

int main()
{
testfunc();

system("pause");

return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
g++ -std=c++11 child2.cpp && ./a.out

child2.cpp:71:29: warning: ISO C++11 does not allow conversion from string literal to 'char *' [-Wwritable-strings]
Parent *p = new Child01("Liu", "zz");
^
child2.cpp:71:36: warning: ISO C++11 does not allow conversion from string literal to 'char *' [-Wwritable-strings]
Parent *p = new Child01("Liu", "zz");
^
2 warnings generated.
Parent construct
Child01 construct
Child01 distruct
Parent distruct

delete运算符

在C中使用malloc和free函数来分配和释放内存,在C++中扩展了new和delete运算符

new和delete运算符的使用

new运算符的使用

1
2
指针变量 = new 类型 (常数);
指针变量 = new 类型 [表达式];

delete运算符的使用

1
2
delete 指针变量;
delete []指针变量;

delete中使用的指针变量必须是一个new返回的指针变量

正确使用:

1
2
3
Parent *p = new Child01("Liu","zz");

delete p;

错误使用:

1
2
3
4
Child01 c("Liu", "zz");
Parent *p = &c;

delete p;

运算符重载

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

其实运算符也可以重载,实际上,我们常常在不知不觉中使用看运算符的重载。

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

也就是说,运算符重载是通过定义函数实现的。运算符重载实质上就是函数的重载。

  • 重载运算符的函数一般格式
1
2
3
4
函数类型 operator 运算符名称(形参表列)
{
对运算符的重载处理
}
  • 例如,重载运算符+
1
2
3
4
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
52
53
54
55
56
57
#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)" << std::endl;
}

int main(int argc, char const *argv[])
{
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
3
4
5
g++ -std=c++11 complex.cpp && ./a.out

c1 = (3, 4i)
c2 = (5, -10i)
c1 + c2 = (8, -6i)

这里如果还有c4,c5,c6等等的时候,第47行表达式会变得很长,代码阅读性就会很差。

  • 当我们懂得一些重载的时候,我们可以这样做
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
#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)" << std::endl;
}

int main(int argc, char const *argv[])
{
Complex c1(3, 4), c2(5, -10), c3;

c3 = c1 + c2; // 这里加号已经被重载了

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

return 0;
}
1
2
3
4
5
g++ -std=c++11 complex2.cpp && ./a.out

c1 = (3, 4i)
c2 = (5, -10i)
c1 + c2 = (8, -6i)

在声明Complex类的时候对运算符进行了重载,使得这个类在用户编程的时候可以完全不考虑函数是如何实现的,直接使用+,-,*,/进行复数的运算即可

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

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

重载运算符的规则

  • C不允许用户自己定义新的运算符,只能对已有的C运算符进行重载
  • 除了以下6个不允许重载以外,其他运算符允许重载:
    • .(成员访问运算符)
    • .*,->*(成员指针访问运算符)
    • ::(域运算符)
    • sizeof(尺寸运算符)
    • ?:(条件运算符)
    • #预处理符号
  • 重载不能改变运算符运算对象(操作数)个数
  • 重载不能改变运算符的优先级别
  • 重载不能改变运算符的结合性
  • 重载运算符的函数不能有默认的参数
  • 重载的运算符必须和用户定义的自定义类型的对象一起使用,其参数至少应该有一个是类对象或类对象的引用。(也就是说,参数不能全部都是c++的标准类型,这样约定是为了防止用户修改用于标准结构的运算符性质)

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

"+"运算符是双目运算符,在刚刚的例子中的重载函数却只有一个参数,这是为什么呢?

实际上,运算符重载函数有两个参数,但由于重载函数是Complex类中的成员函数,有一个参数是隐含着的,运算符函数是用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
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);
friend Complex operator+(Complex &c, 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 &c, Complex &d)
{
return Complex(c.real + d.real, c.imag + d.imag);
}

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

int main(int argc, char const *argv[])
{
Complex c1(3, 4), c2(5, -10), c3;

c3 = c1 + c2; // 这里加号已经被重载了

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

return 0;
}
1
2
3
4
5
g++ -std=c++11 complex3.cpp && ./a.out

c1 = (3, 4i)
c2 = (5, -10i)
c1 + c2 = (8, -6i)

为什么把运算符函数作为友元函数呢?

因为运算符函数要访问Complex类对象的成员,如果运算符函数不是Complex类的友元函数,而是一个普通的函数,它是没有权力访问Complex类的私有成员的。

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

操作符重载的函数原型列表

  • 普通四则运算
1
2
3
4
5
6
7
friend A operator+(const A & lhs, const A & rhs);
friend A operator-(const A & lhs, const A & rhs);
friend A operator*(const A & lhs, const A & rhs);
friend A operator/(const A & lhs, const A & rhs);
friend A operator%(const A & lhs, const A & rhs);
friend A operator*(const A & lhs, const int A & rhs); // 标量运算,如果存在
friend A operator*(const int A & lhs, const A & rhs); // 标量运算,如果存在
  • 关系操作符
1
2
3
4
5
6
7
friend bool operator==(const A & lhs, const A & rhs);
friend bool operator!=(const A & lhs, const A & rhs);
friend bool operator<(const A & lhs, const A & rhs);
friend bool operator<=(const A & lhs, const A & rhs);
friend bool operator>(const A & lhs, const A & rhs);
friend bool operator>=(const A & lhs, const A & rhs);

  • 逻辑操作符
1
2
3
friend bool operator||(const A & lhs, const A & rhs);
friend bool operator&&(const A & lhs, const A & rhs);
bool A::operator!();
  • 正负操作符
1
2
A A::operator+();   // 取正
A A::operator-(); // 取负
  • 递增递减操作符
1
2
3
4
A & A::operator++();    // 前缀递增
A A::operator++(int); // 后缀递增
A & A::operator--(); // 前缀递减
A A::operator--(int); // 后缀递减
  • 位操作符
1
2
3
4
5
6
friend A operator|(const A & lhs, const A & rhs);   // 位与
friend A operator&(const A & lhs, const A & rhs); // 位或
friend A operator^(const A & lhs, const A & rhs); // 位异或
A A::operator<<(int n); // 左移
A A::operator>>(int n); // 右移
A A::operator~(); // 位反
  • 动态储存管理操作符:全局或成员函数均可
1
2
3
4
5
6
void *operator new(std::size_t size) throw(bad_alloc);
void *operator new(std::size_t size, const std::nothrow_t&) throw();
void *operator new(std::size_t size, void *base) throw();
void *operator new[](std::size_t size) throw(bad_alloc);
void operator delete(void *p);
void operator delete[](void *p);
  • 赋值操作符
1
2
3
4
5
6
7
8
9
10
11
12
13
A & operator=(A & rhs);
A & operator=(const A & rhs);
A & operator=(A && rhs);
A & operator+=(const A & rhs);
A & operator-=(const A & rhs);
A & operator*=(const A & rhs);
A & operator/=(const A & rhs);
A & operator%=(const A & rhs);
A & operator&=(const A & rhs);
A & operator|=(const A & rhs);
A & operator^=(const A & rhs);
A & operator<<=(int n);
A & operator>>=(int n);
  • 下标操作符
1
2
T & A::operator[](int i);
const T & A::operator[](int i) const;
  • 函数调用操作符
1
T A::operator()(...);   // 参数可选
  • 类型转换操作符
1
2
3
4
A::operator char*() const;
A::operator int() const;
A::operator double() const

  • 逗号操作符
1
T2 operator,(T1 t1, T2 t2); // 不建议重载
  • 指针与选员操作符
1
2
3
4
5
6
A * A::operator&(); // 取址操作符
A & A::operator*(); // 引领操作符
const A & A::operator*() const; // 引领操作符
C * A::operator->(); // 选员操作符
const C * A::operator->() const; // 选员操作符
C & A::operator->*(...); // 选员操作符,指向类成员的指针
  • 流操作符
1
2
friend ostream& operator<<(ostream& os, const A & a);   // 第一个输入参数os是将要向它写数据的那个流,它是以"引用传递"方式传递的;第二个参数是打算写到那个流里的数据值
friend istream& operator<<(istream& is, const A & a);

C++流的基本概念

原文链接

在C++语言中,数据的输入和输出(简写为I/O)包括对标准输入设备键盘和标准输出设备显示器、对在外存磁盘上的文件和对内存中指定的字符串存储空间(当然可用该空间存储任何信息)进行输入输出这三个方面。

  • 对标准输入设备和标准输出设备的输入输出简称为标准I/O
  • 对在外存磁盘上文件的输入输出简称为文件I/O
  • 对内存中指定的字符串存储空间的输入输出简称为串I/O

C++语言系统为实现数据的输入和输出定义了一个庞大的类库,它包括的类主要有ios,istream,ostream,iostream,ifstream,ofstream,fstream,istrstream,ostrstream,strstream等,其中ios为根基类,其余都是它的直接或间接派生类。

ios为根基类,它直接派生四个类:输入流类istream、输出流类ostream、文件流基类fstreambase和字符串流基类strstreambase,输入文件流类同时继承了输入流类和文件流基类(当然对于根基类是间接继承),输出文件流类ofstream同时继承了输出流类和文件流基类,输入字符串流类istrstream同时继承了输入流类和字符串流基类,输出字符串流类ostrstream同时继承了输出流类和字符串流基类,输入输出流类iostream同时继承了输入流类和输出流类,输入输出文件流类fstream同时继承了输入输出流类和文件流基类,输入输出字符串流类strstream同时继承了输入输出流类和字符串流基类。

“流”就是“流动”,是物质从一处向另一处流动的过程。C流是指信息从外部输入设备(如键盘和磁盘)向计算机内部(即内存)输入和从内存向外部输出设备(如显示器和磁盘)输出的过程,这种输入输出过程被形象地比喻为“流”。为了实现信息的内外流动,C系统定义了I/O类库,其中的每一个类都称作相应的流或流类,用以完成某一方面的功能。根据一个流类定义的对象也时常被称为流。如根据文件流类fstream定义的一个对象fio,可称作为fio流或fio文件流,用它可以同磁盘上一个文件相联系,实现对该文件的输入和输出,fio就等同于与之相联系的文件。

C++系统中的I/O类库,其所有类被包含在iostream.h,fstream.h和strstrea.h这三个系统头文件中,各头文件包含的类如下:

  • iostream.h包含有: ios, iostream, istream, ostream, iostream_withassign, istream_withassign, ostream_withassign等。
  • fstream.h包含有: fstream, ifstream, ofstream和fstreambase,以及iostream.h中的所有类。
  • strstrea.h包含有: strstream, istrstream, ostrstream和strstreambase,以及iostream.h中的所有类。

在一个程序或一个编译单元(即一个程序文件)中当需要进行标准I/O操作时,则必须包含头文件iostream.h,当需要进行文件I/O操作时,则必须包含头文件fstream.h,同样,当需要进行串I/O操作时,则必须包含头文件strstrea.h。在一个程序或编译单元中包含一个头文件的命令格式为“#include<头文件名>”,当然若头文件是用户建立的,则头文件名的两侧不是使用尖括号,而是使用双引号。当系统编译一个C文件对#include命令进行处理时,是把该命令中指定的文件中的全部内容嵌入到该命令的位置,然后再编译整个C文件生成相应的目标代码文件。

C++不仅定义有现成的I/O类库供用户使用,而且还为用户进行标准I/O操作定义了四个类对象,它们分别是cin,cout,cerr和clog,其中cin为istream_withassign流类的对象,代表标准输入设备键盘,也称为cin流或标准输入流,后三个为ostream_withassign流类的对象,cout代表标准输出设备显示器,也称为cout流或标准输出流,cerr和clog含义相同,均代表错误信息输出设备显示器。因此当进行键盘输入时使用cin流,当进行显示器输出时使用cout流,当进行错误信息输出时使用cerr或clog。

在istream输入流类中定义有对右移操作符>>重载的一组公用成员函数,函数的具体声明格式为:

  • istream& operator>>(简单类型标识符&);

简单类型标识符可以为char, signed char, unsigned char, short, unsigned short, int, unsigned int, long, unsigned long, float, double, long double, char*, signed char*, unsigned char*之中的任何一种,对于每一种类型都对应着一个右移操作符重载函数。由于右移操作符重载用于给变量输入数据的操作,所以又称为提取操作符,即从流中提取出数据赋给变量。

当系统执行cin>>x操作时,将根据实参x的类型调用相应的提取操作符重载函数,把x引用传送给对应的形参,接着从键盘的输入中读入一个值并赋给x(因形参是x的别名)后,返回cin流,以便继续使用提取操作符为下一个变量输入数据。

当从键盘上输入数据时,只有当输入完数据并按下回车键后,系统才把该行数据存入到键盘缓冲区,供cin流顺序读取给变量。还有,从键盘上输入的每个数据之间必须用空格或回车符分开,因为cin为一个变量读入数据时是以空格或回车符作为其结束标志的。

当cin>>x操作中的x为字符指针类型时,则要求从键盘的输入中读取一个字符串,并把它赋值给x所指向的存储空间中,若x没有事先指向一个允许写入信息的存储空间,则无法完成输入操作。另外从键盘上输入的字符串,其两边不能带有双引号定界符,若带有只作为双引号字符看待。对于输入的字符也是如此,不能带有单引号定界符。

在ostream输出流类中定义有对左移操作符<<重载的一组公用成员函数,函数的具体声明格式为:

  • ostream& operator<<(简单类型标识符);

简单类型标识符除了与在istream流类中声明右移操作符重载函数给出的所有简单类型标识符相同以外,还增加一个void* 类型,用于输出任何指针(但不能是字符指针,因为它将被作为字符串处理,即输出所指向存储空间中保存的一个字符串)的值。由于左移操作符重载用于向流中输出表达式的值,所以又称为插入操作符。如当输出流是cout时,则就把表达式的值插入到显示器上,即输出到显示器显示出来。

当系统执行cout<操作时,首先根据X值的类型调用相应的插入操作符重载函数,把X的值按值传送给对应的形参,接着执行函数体,把X的值(亦即形参的值)输出到显示器屏幕上,从当前屏幕光标位置起显示出来,然后返回COUT流,以便继续使用插入操作符输出下一个表达式的值。当使用插入操作符向一个流输出一个值后,再输出下一个值时将被紧接着放在上一个值的后面,所以为了让流中前后两个值分开,可以在输出一个值之后接着输出一个空格,或一个换行符,或其他所需要的字符或字符串。

多继承(multiple inheritance)

  • 什么时候用到多继承?
    • 只要你遇到的问题无法只用一个"是一个"关系来描述的时候,就是多继承登场的时候
    • 举个例子:在学校里有老师和学生,它们都是人(Person),我们可以用"老师是人"和"学生是人"语法来描述这种情况
    • 从面向对象编程角度来看,我们应该创建一个名为Person的基类和2个名为Teacher和Student的子类,后两者是从前者继承来的
  • 那么问题来了:有一部分学生还教课赚钱(助教),该怎么办?这样就存在既是老师又是学生的复杂关系,也就是同时存在着两个"是一个"关系
  • 我们需要写一个TeachingStudent类让它同时继承Teacher类和Student类,换句话说,就是需要使用多继承
  • 基本语法:
1
2
3
class TeachingStudent : public Student, public Teacher{

}

下面演示一下这个多继承的模型:

  • 要求:创建一个由Person,Teacher,Student和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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
#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;
};

// 多继承
class TeachingStudent : public Student, public Teacher
{
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 << "。" << std::endl;
}

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

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

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

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

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

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

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 << "学习。" << std::endl;
}

int main(int argc, char const *argv[])
{
Teacher teacher("A老师", "C++入门班");
Student student("小A", "C++入门班");
TeachingStudent teachingStudent("小B", "C++入门班", "C++进阶班");

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

return 0;
}
1
2
3
4
5
6
7
8
9
g++ -std=c++11 multiple_inheritance.cpp && ./a.out

大家好,我是A老师,我教C++入门班。
A老师教C++入门班。
大家好,我是小A,我在C++入门班学习。
小A加入C++入门班学习。
大家好,我是小B。我教C++入门班,同时我在C++进阶班学习。
小B教C++入门班。
小B加入C++进阶班学习。

虚继承(virtual inheritace)

上一节的student例子中存在着一些隐患。

首先,在TeachingStudent类的introduce()方法里,我们不得不明确地告诉编译器应该使用哪一个属性。

这对于classes属性来说是应该的,因为教一门课和上一门课有着本质的区别,而作为常识,助教教的课程和其他学的课程不可能一样。

在深入考虑一下,既然在TeachingStudent对象里可以继承两个不同的classes属性,那它是不是应该有两个不同的name属性呢?

答案:是!事实上,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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
#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;
};

// 多继承
class TeachingStudent : public Student, public Teacher
{
public:
TeachingStudent(std::string theName1, std::string theName2, std::string classTeaching, std::string classAttending);

void introduce();
};

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

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

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

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

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

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

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

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

TeachingStudent::TeachingStudent(std::string theName1,
std::string theName2,
std::string classTeaching,
std::string classAttending)
: Teacher(theName1, classTeaching), Student(theName2, classAttending)
{
}

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

int main(int argc, char const *argv[])
{
Teacher teacher("A老师", "C++入门班");
Student student("小A", "C++入门班");
TeachingStudent teachingStudent("小B", "小C", "C++入门班", "C++进阶班");

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

return 0;
}
1
2
3
4
5
6
7
8
9
g++ -std=c++11 multiple_inheritance2.cpp && ./a.out

大家好,我是A老师,我教C++入门班。
A老师教C++入门班。
大家好,我是小A,我在C++入门班学习。
小A加入C++入门班学习。
大家好,我是小C。我教C++入门班,同时我在C++进阶班学习。
小B教C++入门班。
小C加入C++进阶班学习。

我们发现,确实可以存在两个名字,这不是我们所期望的(人格分裂),也是程序的漏洞。(比如当我们设计password的时候,显然不希望2个password都可以执行)

TeachingStudent类继承自Teacher和Student两个类,因而继承了两组Person类的属性,这些在某些时候完全有道理,例如classes属性,但是也有可能引起麻烦,例如发生在name属性身上的情况。

C++的发明者也想到了这部分的冲突,因此为此提供了一个功能可以解决这个问题:虚继承。

通过虚继承某个基类,也就是告诉编译器:从当前这个类再派生出来的子类只能拥有那个基类的一个属性

  • 虚继承的语法:
1
2
3
class Teacher : virtual public Person{

}
  • 这样做我们的问题就解决了:让Student类和Teacher类都虚继承自Person类,编译器将确保从Student类和Teacher类再派生出来的子类只能拥有一份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
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
109
110
111
112
113
114
115
116
#include <iostream>
#include <string>

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

void introduce();

protected:
std::string name;
};

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;
};

// 多继承
class TeachingStudent : public Student, public Teacher
{
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 << "。" << std::endl;
}

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

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

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

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

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

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

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

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

int main(int argc, char const *argv[])
{
Teacher teacher("A老师", "C++入门班");
Student student("小A", "C++入门班");
TeachingStudent teachingStudent("小B", "C++入门班", "C++进阶班");

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

return 0;
}
1
2
3
4
5
6
7
8
g++ -std=c++11 virtual_inheritance.cpp && ./a.out  
大家好,我是A老师,我教C++入门班。
A老师教C++入门班。
大家好,我是小A,我在C++入门班学习。
小A加入C++入门班学习。
大家好,我是小B。我教C++入门班,同时我在C++进阶班学习。
小B教C++入门班。
小B加入C++进阶班学习。

注意91行以及97行,这里name属性是直接来自于Person类的,因为TeacherStudent类都是虚继承这个Person类的,所以无法拥有它的备份,不能再继承出去。

错误处理和调试

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

编译时错误

相比之下,编译时错误显然是比较轻的。因为编译器将会告诉你发现了什么错误和它是在哪行代码发现类这个错误。

我们要做的只是认真观察和分析编译器给出的出错信息,然后按语法要求改正即可。

下面是几条经验

  1. 培养并保持一种编程风格
  2. 认真对待编译器给出的错误/警告信息
  3. 三思而后行
  4. 注意检查最基本的语法
  5. 把可能有问题的代码行改为注释
  6. 换一个环境或开发工具试试
  7. 检查自己是否已经把所有必要的头文件全部include进来
  8. 留意变量的作用域和命名空间
  9. 休息一下
  10. 使用调试工具
  11. 避免错误的另一个好方法就是把调试好的代码另外保存起来并不再改动它

运行时错误

运行时错误往往比编译时错误更难以查找和纠正,运行时错误一般都不会有正式的出错信息。

它们的发生几率因不同程序思路不同而不同,很少有规律可循

更多的情况是时有时无,有的程序在这台计算机上很正常,在另一台计算机上就总是出问题。或者,某几个用户经常遇到这样或那样的问题,其他用户却都正常。

下面是几条经验

  1. 还是培养并保持一种良好的编程风格!
  2. 多用注释,用好注释
  3. 注意操作符的优先级
  4. 千万不要忘记对用户输入和文件输入进行合法性检查
  5. 不要做任何假设
  6. 把程序划分成一些比较小的单元模块来测试

让函数返回错误代码

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

例如:当我们计算阶乘的值超出了计算机所能表达的最大整数。

鉴于这类问题的纠正,我们可以利用climits头文件。

  • 这个头文件从C的limits.h头文件引用过来的。主要列出了各种数据类型在给定操作系统上的取值范围,并且把每种数据类型的最大可取值和最小取值都分别定义为一个常量供我们比较。
  • 比如,SHORT_MAX代表短整数类型在给定系统上的最大可取值,SHORT_MIN代表短整数类型在给定操作系统上的最小可取值。USHORT_MAX代表无符号整数类型的最大可取值。

为了判断阶乘计算的结果没有超出一个无符号长整数的最大取值,我们可以使用ULONG_MAX来提前获取这个值进行对比。

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

class Factorial
{
public:
Factorial(unsigned short num);
unsigned long getFactorial();
bool inRange();

private:
unsigned short num;
};

Factorial::Factorial(unsigned short num)
{
this->num = num;
}

unsigned long Factorial::getFactorial()
{
unsigned long sum = 1;

for (int i = 1; i <= num; i++)
sum *= i;

return sum;
}

bool Factorial::inRange()
{
unsigned long max = ULONG_MAX;

for (int i = num; i >= 1; i--)
max /= i;

if (max < 1)
return false;
else
return true;
}

int main(int argc, char const *argv[])
{
unsigned short num = 0;

std::cout << "请输入一个整数: ";
std::cin >> num;

Factorial fac(num);

if (fac.inRange())
std::cout << num << "的阶乘值是" << fac.getFactorial() << std::endl;
else
std::cout << "您所输入的值太大" << std::endl;

return 0;
}
1
2
3
4
g++ -std=c++11 factorial.cpp && ./a.out

请输入一个整数: 5
5的阶乘值是120
1
2
3
4
g++ -std=c++11 factorial.cpp && ./a.out

请输入一个整数: 100
您所输入的值太大

assert函数和捕获异常

assert函数

assert函数是专为调试而准备的工具函数。这个函数是在C语言的assert.h的库文件里定义的,所以包含到C++程序里我们用以下语句:

1
#include <cassert>
  • assert()函数需要有一个参数,他将测试这个输入参数的真or假状态
    • 如果为真: Do nothing!
    • 如果为假: Do something!
1
2
3
4
5
6
7
8
9
#include <cassert>

int main(int argc, char const *argv[])
{
int i = 20;
assert(i == 65);

return 0;
}
1
2
3
4
g++ -std=c++11 test.cpp && ./a.out

Assertion failed: (i == 65), function main, file test.cpp, line 6.
[1] 81148 abort ./a.out

我们可以利用它在某个程序里的关键假设不成立时立即停止该程序的执行并报错,从而避免发生更严重的问题。

另外,除了结合assert()函数,在程序的开发,测试阶段,我们还可以使用大量的cout语句来报告在程序里正在发生的事情。

捕获异常

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

基本使用思路:

  • 安排一些C++代码(try语句)去尝试某件事–尤其是那些可能会失败的事(比如打开一个文件或申请一些内存)
  • 如果发生问题,就抛出一个异常(throw语句)
  • 再安排一些代码(catch语句)去捕获这个异常并进行相应的处理
1
2
3
4
5
6
7
8
9
try
{
// Do something
// Throw an exception on error
}
catch
{
// Do whatever
}

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

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

  • 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
42
43
44
45
46
47
48
49
50
#include <iostream>
#include <climits>

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

int main(int argc, char const *argv[])
{
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 << std::endl;
}
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;
}
1
2
3
4
g++ -std=c++11 exception.cpp && ./a.out

请输入一个整数: 100
基数太大,无法在该计算器计算求出阶乘值!

如果throw抛出异常就会在catch中捕获异常,代码第27行就会打印出来,try中第23行将不会被执行。因为第22行函数中已经抛出异常了。

如何处理异常是一个很容易引起争议的话题,有些程序员使用异常来处理几乎所的错误,C++创始人认为他们正在被滥用。

使用处理异常的基本原则是:应该只用它们来处理确实可能不正常的情况

在构造器和析构器里不应该使用异常

一个有经验的程序员在这些方法里成功的使用里异常是有可能的,但是稍有不慎就会导致严重的问题。

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

  • 在C++标准库里有一个名为exception的文件,该文件声明里一个exception的基类。可以使用这个基类来创建个人的子类以管理异常
    • 有经验的程序员常常这么做,而如此抛出和捕获的是exception类或其子类的对象
    • 如果你打算使用对象作为异常,请记住这样一个原则:以值传递的方式抛出对象,以引用传递的方式捕获对象

动态内存管理

到目前为止,上面的示例程序在完成它的任务时所使用的内存空间都是固定不变的。

这个固定不变的内存空间其不实是在编写程序时候就知道和确定(一般以变量的形式)。这些程序都不能再程序运行期间动态增加或减少内存空间。

我们一定没见过要求用户输入的文本必须不多不少包含多少个字符的程序。在很多时候,需要储存的数据量到底有多大往往事先是一个未知数,要想处理好这类情况,就需要在C++程序里使用动态内存。

动态内存支持程序员创建和使用种种能够根据具体需要扩大和缩小的数据结构,它们只受限于计算机的硬件内存总量和系统特殊约束(操作系统会占据一定的内存空间)。

静态内存

静态内存就是我们此前一直在使用的东西,变量(包括指针变量),固定长度的数组,某给定类的对象。我们可以在程序代码里通过它们的名字或地址来访问和使用它们。

使用静态内存最大的弊端是,你不得不在编写程序时为有关变量分配一块尽可能大的内存(以防止不够存放数据)。一旦程序开始运行,不管实际情况如何,那个变量都将占用那么多的内存,没有任何办法能改变静态内存的大小。

动态内存

动态内存由一些没有名字,只有地址的内存块构成,那些内存块是在程序运行期间动态分配的。

它们来自一个由标准C++库替你管理的内存池。

从内存池申请一些内存需要使用new语句,它将根据你提供的数据类型分配一块大小适当的内存。你不必担心内存块的尺寸问题,编译器能够记住每一种数据类型的长度单位并迅速计算出需要分配多少个字节。

只要有足够的可用内存能满足你的申请,new语句将返回新分配地址块的起始地址。

  • 如果没有足够的可用内存空间呢
    • 那么new语句将抛出std::bad_alloc异常

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

NULL指针

有一种特殊的地址值叫做NULL指针。当把一个指针变量设置为NULL时,它的含义是那个指针将不在指向任何东西:

1
2
int *x;
x = NULL; // x这个时候什么都不指向

我们将无法通过一个被设置为NULL的指针去访问数据。事实上,试图对一个NULL指针进行解引用将在运行时被检测到并将导致程序中止执行。

所以在用delete释放内存后,指针会保留一个毫无意义的地址,我们要将指针变量赋值为NULL。


注意:

  • 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
53
54
55
56
57
58
59
#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 << "。" << std::endl;
}

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

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

int main(int argc, char const *argv[])
{
Company *company = new Company("APPLE");
company->printInfo();

delete company;
company = NULL;

company = new TechCompany("APPLE", "IPHONE");
company->printInfo();

delete company;
company = NULL;

return 0;
}
1
2
3
4
g++ -std=c++11 dynamic_memory.cpp && ./a.out

这个公司的名字叫: APPLE。
APPLE公司大量生产了IPHONE这款产品!

注意:

  • 搞对象的时候,千万不要忘记把方法声明为虚方法
    • 上面的例子中printInfo()方法如果没有声明成虚方法的话,调用的时候会调用成基类的方法
  • 重新使用某个指针之前千万不要忘记调用delete语句,如果不这样做,那个指针将得到一个新内存块的地址,而程序将永远也无法释放原来那个内存块,因为它的地址已经被覆盖掉了
  • delete语句只释放给定指针变量正指向的内存块,不影响这个指针。在执行delete语句之后,那个内存块被释放了,但是指针变量还依然健在!(所以要设置成NULL)

动态数组

虽然上一节说过用new给基本类型和对象在运行时分配内存,但它们的尺寸在编译时就已经确定下来。因为我们为之申请内存的数据类型在程序里有明确的定义,有明确的单位长度。

但是有时候必须要等到程序运行时才能确定需要申请多少内存,甚至还要根据程序的运行情况追加申请更多的内存。

这一节将编写一个程序为一个整数型数组分配内存,实现动态数组。

这个程序是能够在运行时让用户输入一个值自行定义数组的长度。这意味着数组的长度在编写这个程序的时候是未知的,无法在定义这个数组时在方括号里给出一个准确的数字。

  • int a[???];

这个时候可以通过指针来解决这个问题。

数组名和下标操作符[]的组合可以被替换成一个指向该数组的基地址的指针和对应的指针运算:

  • int a[20];
  • int *x = a;

指针变量x指向数组a的地址,a[0]和*x都代表数组的第一个元素。

根据指针运算原则,a[1]等价于*(x+1),a[2]等价于*(x+2),以此类推。

把这个逻辑反过来也是成立的,同时我们发现:

  • 把一个数组声明传递给new语句将使它返回一个该数组基类型的指针
  • 把数组下标操作符和该指针变量的名字搭配使用就可以对待一个数组那样使用new语句为这个数组分配的内存块了

例如:

  • int *x = new int [10];
  • 可以像对待一个数组那样使用指针变量x
    • x[1] = 45;
    • x[2] = 8;
  • 当然也可以用一个变量来保存该数组的元素个数:
    • int count = 10;
    • int *x = new int[count];

删除一个动态数组要比删除其他动态数据类型稍微复杂一些。因为用来保存数组地址的变量只是一个简单的指针,所以需要明确地告诉编译器它应该删除一个数组!

具体的做法是在delete保留字的后面加上一对方括号:delete[]x;

释放一个动态数组时,或者说是指向数组的指针时,空括号是必须的。它告诉编译器,指针指向一个数组的第一个元素。

delete释放数组是逆序进行的,最后一个元素被最先释放,第一个元素最后一个被释放。

使用动态数组时,一定要记得显式的释放内存,否则很容易出错,比如在一个大的工程中,某一个for循环中或者某个函数中申请了内存却没释放,当函数不断地被调用,这些申请过的内存会一直堆积,直到最后退出程序。这很可能造成非常大的麻烦。

下面通过一个示例演示:

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

int main(int argc, char const *argv[])
{
unsigned int count = 0;

std::cout << "请输入数组的元素个数: " << std::endl;
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] << std::endl;

delete []x;

return 0;
}
1
2
3
4
5
6
7
8
9
10
g++ -std=c++11 dynamic_array.cpp && ./a.out

请输入数组的元素个数:
6
x[0]的值是: 0
x[1]的值是: 1
x[2]的值是: 2
x[3]的值是: 3
x[4]的值是: 4
x[5]的值是: 5

从函数或方法返回内存

动态内存的另一个常见的用途是让函数申请并返回一个指向内存块的指针。掌握这个技巧很重要,尤其是在你打算使用由别人编写的库文件时。

副本构造器

高级强制类型转换

避免内存泄露

命名空间和模块化编程

链接和作用域

函数模版

类模版

内存模版

容器和算法