面向对象的基本概念

面向对象的意义

  • 将日常生活中习惯的思维方式引入程序设计中
  • 将需求中的概念直观的映射到解决方案中
  • 以模块为中心构建可复用的软件系统
  • 提高软件产品的可维护性和可扩展性

类和对象是面向对象中的两个基本概念

  • 类:指的是一类事物,是一个抽象的概念
  • 对象:指的是属于某个类的具体实体
  • 类是一种模型,这种模型可以创建出不同的对象实体
  • 对象实体是类模型的一个具体实例

一个类可以有多个对象,而一个对象必然属于某个类

类和对象的意义

  • 类用于抽象的描述一类事务所特有的属性和行为
    如:电脑拥有CPU,内存和硬盘,并且可以开机和运行程序
  • 对象是具体的事物,拥有所属类中描述的一切属性和行为
    如:每一只老虎都有不同体重,不同食量等

一些有趣问题

类一定存在实际的对象吗?
不一定,比如:恐龙,理论上来看,考古学家通过化石构建出一个恐龙,但是现在并不存在一个真真切切的恐龙

类的对象数目是确定的吗?
不一定确定,比如有多少只老虎?

类一定来源于现实生活?
不一定,虽然将现实中类的思想移到编程思想中,但一些辅助类需要我们自己抽象构建

类都是独立的吗?类之间存在关系吗?
类不是独立的,类之间显然存在关系

对象实例一定只属于一个类?
不一定。

对象实例可能完全相同吗?
现实中没有两片相同的叶子,程序设计中有吗?

类之间的基本关系

继承

  • 从已存在类细分出来的类和原类之间具有继承关系(is-a)
  • 继承的类(子类)拥有原类(父类)的所有属性和行为

比如华南虎和老虎是继承关系(单向)。华南虎 is a 老虎而不能反过来说老虎 is a 华南虎

image-20220223101553830

组合

  • 一些类的存在必须依赖于其它的类,这种关系叫组合
  • 组合的类在某一局部上有其他类组成

电脑由CPU,主板,内存,硬盘等,而电脑这个类依赖于CPU类,主板类,内存类,硬盘类等组成

image-20220223101622484

组合关系的特点
  • - 挎其它类的对象作为当前类的成员使用
  • - 当前类的对象与成员对象的生命期相同
  • - 成员对象在用法上与普通对象完全一致

类的表示法

image-20220223101825929

但是编译器懂这个图吗?我们可以简化成编译器比较能理解的表示法

image-20220223102106606

上面图属性重复定义,我们继续改进

image-20220223102250646

继续改进,使用了大括号

image-20220223102347949

继续改进,我们用struct来代替中文的。用:来代替中文的继承

image-20220223102444475

我们继续改进,用变量描述属性,函数描述行为

image-20220223102625754

经过多次改进,这样表示不遗漏信息且成功让C++编译器能够读懂。

类与封装

类通常分为以下两个部分

  • 类的使用细节
  • 类的使用方式

image-20220223103523272

  • 当使用类的时候,不需要关心其内部实现细节
  • 当创建类时,才需要考虑其内部实现细节

例如
普通用户使用手机,只需要学习如何发短信,打电话,拍照,等等等
手机开发工程师,需要考虑手机内部的实现细节

封装的基本概念

  • 根据经验:并不是类的每个属性都是对外公开的
    比如:女孩子不希望外人知道自己的体重和年龄
    比如:男孩子不希望别人知道自己的身高和收入

  • 而一些类的属性时对外公开的
    比如:人的姓名,学历,国籍等

  • 必须在类的表示法中定义属性和行为的公开级别
    类似文件系统中文件的权限

C++中类的封装

  • 成员变量:C++中用于表示类属性的变量
  • 成员函数:C++中用于表示类行为的函数
  • C++中可以给成员变量和成员函数定义访问级别
    public 成员变量和成员函数可以在类的内部和外界访问和调用
    private 成员变量和成员函数只能在类的内部被访问和调用

类成员的作用域

  • 类成员的作用域都只在类的内部,外部无法直接访问
  • 成员函数可以直接访问成员变量和调用成员函数
  • 类的外部可以通过类变量访问public成员
  • 类成员的作用域与访问级别没有关系

C++中用struct定义的类中所有成员默认为public

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 <stdio.h>

int i = 1;

struct Test
{
private:
int i;

public:
int j;

int getI()
{
i = 3;
return i;
}
};

int main()
{
int i = 2;

Test test;

test.j = 4;

printf("i = %d\n", i); // i = 2;
printf("::i = %d\n", ::i); // ::i = 1;注意::访问默认命名空间(全局作用域)
// printf("test.i = %d\n", test.i); // Error
printf("test.j = %d\n", test.j); // test.j = 4
printf("test.getI() = %d\n", test.getI()); // test.getI() = 3

return 0;
}

类的真正形态

一个问题:
经过不停的改进,结构体struct变得越来越不像它原来在C语言的样子了

类的关键字

  • struct在C语言中已经有了自己的含义,必须继续兼容
  • 在C++中提供了新的关键字class用于类定义
  • class和struct的用法是完全相同

class和struct区别

在用struct定义类是,所有的成员的默认访问级别为public

在用class定义类时,所有成员的默认访问级别为private

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
#include <stdio.h>

struct A
{
// defualt to public
int i;
// defualt to public
int getI()
{
return i;
}
};

class B
{
// defualt to private
int i;
// defualt to private
int getI()
{
return i;
}
};

int main()
{
A a;
B b;

a.i = 4;

printf("a.getI() = %d\n", a.getI());

b.i = 4;

printf("b.getI() = %d\n", b.getI());

return 0;
}
  • C++中的类支持声明和实现的分离
  • 将类的实现和定义分开
    .h头文件中只有类的声明(成员变量和成员函数的声明)
    .cpp源文件中完成类的其他实现(成员函数的具体实现)

对象的构造

一个问题:对象中成员变量的初始值是多少?

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
#include <stdio.h>

class Test
{
private:
int i;
int j;
public:
int getI() { return i; }
int getJ() { return j; }
};

Test gt;

int main()
{
printf("gt.i = %d\n", gt.getI()); //全局变量
printf("gt.j = %d\n", gt.getJ());

Test t1;

printf("t1.i = %d\n", t1.getI()); //栈上
printf("t1.j = %d\n", t1.getJ());

Test* pt = new Test;

printf("pt->i = %d\n", pt->getI()); //堆空间上
printf("pt->j = %d\n", pt->getJ());

delete pt;

return 0;
}
1
2
3
4
5
6
7
8
fengyun@ubuntu:~/share$ g++ test.cpp -o test
fengyun@ubuntu:~/share$ ./test
gt.i = 0
gt.j = 0
t1.i = 1139214448
t1.j = 32765
pt->i = 0
pt->j = 0

类其实是一种数据类型,这种数据类型定义的变量即可在静态变量区,也可以在栈空间,也可以在堆空间。

因此gt.i,gt.j在静态变量区,初始值为0。t1.i,t1.j在栈上初始化为随机值,而pt->i,pt->j在堆上随机初始化,这里结果是0只是一个巧合。

对象的初始化

生活中的对象都是在初始化后上市的
初始状态是对象普遍存在的一个状态

解决方案:

  • 在类中提供一个public的initialize函数
  • 对象创建后立即调用initialize函数进行初始化
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
#include <stdio.h>

class Test
{
private:
int i;
int j;
public:
int getI() { return i; }
int getJ() { return j; }
void initialize()
{
i = 1;
j = 2;
}
};

Test gt;

int main()
{
gt.initialize();

printf("gt.i = %d\n", gt.getI());
printf("gt.j = %d\n", gt.getJ());

Test t1;

//t1.initialize();

printf("t1.i = %d\n", t1.getI());
printf("t1.j = %d\n", t1.getJ());

t1.initialize();

Test* pt = new Test;

pt->initialize();

printf("pt->i = %d\n", pt->getI());
printf("pt->j = %d\n", pt->getJ());

delete pt;

return 0;
}

观察我们的代码,initialize()函数是一个普通函数,要求我们自己手工调用,而如果忘记调用或者调用顺序不对,运行结果可能是不对的

因此我有一个大胆的想法:能不能让initialize在对象定义时自动被调用?

构造函数

C++可以定义与类名相同的特殊成员函数,这种特殊的成员函数叫做构造函数

  • 构造没有任何返回类型的声明
  • 构造函数在对象定义时自动被调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Test
{
private:
int i;
int j;
public:
int getI() { return i; }
int getJ() { return j; }
Test()
{
printf("Test() Begin\n");

i = 1;
j = 2;

printf("Test() End\n");
}
};

带有参数的构造函数

  • 构造函数可以根据需要定义参数
  • 一个类中可以存在多个重载的构造函数
  • 构造函数的重载遵循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
#include <stdio.h>

class Test
{
public:
Test()
{
printf("Test()\n");
}
Test(int v)
{
printf("Test(int v), v = %d\n", v);
}
};

int main()
{
Test t; // 调用 Test()
Test t1(1); // 调用 Test(int v)
Test t2 = 2; // 调用 Test(int v)

int i(100);

printf("i = %d\n", i);

return 0;
}

注意初始化的写法,回想C语言int i = 100;这是初始化,而int i(100);这也是初始化。
同样地,Test t2 = 2;也是初始化,Test t2(2);也是初始化。

但是注意

1
2
3
4
   Test t;      // 调用 Test()
Test t2 = 2; // 调用 Test(int v)

t = t2; // 赋值

这里t = t2是赋值而不是初始化,初始化是初始化,赋值是赋值,二者不可混为一谈!

初始化与赋值不同

初始化:对正在创建的对象进行初值设置

赋值:对已经存在的对象进行值设置

构造函数的手动调用【特殊情况】

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
#include <stdio.h>

class Test
{
private:
int m_value;
public:
Test()
{
printf("Test()\n");
m_value = 0;
}
Test(int v)
{
printf("Test(int v), v = %d\n", v);
m_value = v;
}
int getValue(){
return m_value;
}
};

int main()
{
Test ta[3] = {Test(), Test(1), Test(2)};

for(int i = 0;i < 3;i++){
printf("ta[%d].getValue() = %d\n",i,ta[i].getValue());
}

Test t = Test(100); //

printf("t.getValue() = %d\n", t.getValue());

return 0;
}
1
2
3
4
5
6
7
8
9
10
fengyun@ubuntu:~/share$ g++ test.cpp -o test
fengyun@ubuntu:~/share$ ./test
Test()
Test(int v), v = 1
Test(int v), v = 2
ta[0].getValue() = 0
ta[1].getValue() = 1
ta[2].getValue() = 2
Test(int v), v = 100
t.getValue() = 100

特殊的构造函数

  • 无参构造函数 :没有参数的构造函数
    当类中没有定义任意的构造函数时,编译器默认提供一个无参构造函数,并且其函数体为空
    反过来说,假设我们的类中已经定义了构造函数,编译器将不再提供无参构造函数
  • 拷贝构造函数 :参数为const class_name&的构造函数
    当类中没有定义拷贝构造函数时,编译器默认提供一个拷贝构造函数,简单的进行成员变量的值复制。

一道经典面试题:以下这个类有什么东西?

1
2
3
4
class T
{

};

C++空类的大小不为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
#include <stdio.h>

class Test
{
private:
int i;
int j;
public:
int getI()
{
return i;
}
int getJ()
{
return j;
}
/*
Test(const Test& t)
{
i = t.i;
j = t.j;
}

Test()
{
}
*/
};

int main()
{
Test t1;
Test t2 = t1;

printf("t1.i = %d, t1.j = %d\n", t1.getI(), t1.getJ());
printf("t2.i = %d, t2.j = %d\n", t2.getI(), t2.getJ());

return 0;
}

运行上述程序,观察结果如下,可以知道调用了默认的构造函数Test t1和默认的拷贝构造函数Test t2 = t1

1
2
3
4
fengyun@ubuntu:~/share$ g++ test.cpp -o test
fengyun@ubuntu:~/share$ ./test
t1.i = -1661124032, t1.j = 21985
t2.i = -1661124032, t2.j = 21985

注意,如果我给上述代码仅仅只提供一个拷贝构造函数,

image-20220223154108556

编译报错,这是因为编译器觉得我们已经给类提供了一个构造函数(拷贝构造函数也是构造函数),编译器就不给我们提供无参构造函数了,因此在代码32行的Test t;这行编译器报错error: no matching function for call to ‘Test::Test()’

拷贝构造函数

拷贝构造函数的意义

  • 浅拷贝 :拷贝后的对象的物理状态相同
  • 深拷贝 :拷贝后的对象的逻辑状态相同

编译器提供的默认拷贝函数仅仅是浅拷贝。

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 <stdio.h>

class Test
{
private:
int i;
int j;
int* p;
public:
int getI()
{
return i;
}
int getJ()
{
return j;
}
int* getP()
{
return p;
}
void free()
{
delete p;
}

// Test(const Test& t)
// {
// i = t.i;
// j = t.j;
// p = new int;
// *p = *t.p;
// }

Test(int v)
{
i = 1;
j = 2;
p = new int;
*p = v;
}

};

int main()
{
Test t1(3);
Test t2 = t1;

printf("t1.i = %d, t1.j = %d, t1.p = %p\n", t1.getI(), t1.getJ(), t1.getP());
printf("t2.i = %d, t2.j = %d, t2.p = %p\n", t2.getI(), t2.getJ(), t2.getP());

return 0;
}

我们继续修改代码,给类对象增加一个int型指针p并且删除拷贝构造函数,创建一个我们写的构造函数

然后打印,可以看到两个对象t1和t2的p指针都指向堆的同一块内存

1
2
3
4
fengyun@ubuntu:~/share$ g++ test.cpp -o test
fengyun@ubuntu:~/share$ ./test
t1.i = 1, t1.j = 2, t1.p = 0x5641c9546eb0
t2.i = 1, t2.j = 2, t2.p = 0x5641c9546eb0

但是如果我们在main函数分别用t1,t2两个对象调用成员函数free()释放对应的内存则会造成重复释放同一块内存的严重错误!

image-20220223155328392

image-20220223160303269

既然t1初始化的时候指向了堆空间的一块内存,我们t2拷贝构造的时候应该指向堆空间的一块新的内存。

1
2
3
4
5
6
7
Test(const Test& t)
{
i = t.i;
j = t.j;
p = new int;
*p = *t.p;
}

现在虽然p指针变量存储的地址变量不一样的,但是指向的内存空间存储的值是一样的。

这也就是深拷贝。

什么时候需要进行深拷贝?

对象中有成员指代了系统中的资源

  • 成员指向了动态内存空间
  • 成员打开来外存中的文件
  • 成员使用了系统中的网络端口

一般性原则自定义拷贝构造函数,必然要实现深拷贝!!!

初始化列表的使用

类中是否可以定义const成员?

答案:可以,是一个只读变量。

下面的类定义是否合法?如果合法,ci的值是什么,存储在哪里?

1
2
3
4
5
6
7
8
9
class Test
{
private:
const int ci;
public:
int getCI(){
return ci;
}
}

注意,类定义合法,但是未对const int ci进行初始化,我们要定义一个构造函数来初始化ci变量,但是ci是一个只读变量不能作为左值,因此我们引出了初始化列表,用初始化列表对ci进行初始化。

C++中提供了初始化列表对成员变量进行初始化

语法规则

1
2
3
4
ClassName::ClassName():m1(v1), m2(v2), m3(v3)
{
// some other initialize operation
}

成员初始化顺序

  • 成员的初始化顺序与成员的声明顺序相同
  • 成员的初始化顺序与初始化列表中的位置无关
  • 初始化列表先于构造函数的函数体执行

测试程序:

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
#include <stdio.h>

class Value
{
private:
int mi;
public:
Value(int i)
{
printf("i = %d\n", i);
mi = i;
}
int getI()
{
return mi;
}
};

class Test
{
private:
Value m2;
Value m3;
Value m1;
public:
Test() : m1(1), m2(2), m3(3)
{
printf("Test::Test()\n");
}
};


int main()
{
Test t;

return 0;
}

Test类的三个成员Value类m1,m2,m3应该如何初始化呢?

1
2
3
4
private:
Value m2(2);
Value m3(3);
Value m1(1);

首先这种方式是不允许的,语法错误。要初始化成员变量只能使用初始化列表。(c++11新特性允许为数据成员提供类内初始值)

改为Test() : m1(1), m2(2), m3(3)接着运行,成功运行。

1
2
3
4
5
6
fengyun@ubuntu:~/share$ g++ test.cpp -o test
fengyun@ubuntu:~/share$ ./test
i = 2
i = 3
i = 1
Test::Test()

如图所示,可以看到一个特别奇怪的现象:

  • 成员的初始化顺序与初始化列表中的位置无关
  • 成员的初始化顺序与成员的声明顺序相同
  • 初始化列表先于构造函数的函数体执行

这个十分的重要!!!

类中const成员

  • 类中的const成员会被分配空间的
  • 类中的const成员的本质是只读变量
  • 类中的const成员只能在初始化列表中指定初始值

对象的构造顺序

局部对象的构造顺序

C++的类可以定义多个对象,那么对象的构造顺序怎样的

对于局部对象

当程序执行流到达对象的定义语句时进行构造

1
2
3
4
5
6
7
8
9
10
int i = 0;
Test a1 = i;

while(i < 3)
Test a2 = ++i;

if(i < 4)
Test a = a1;
else
Test a(100);

image-20220224093834998

可以看到执行顺序非常正常

但是如果增加goto语句可能忽略某些类的构造而未进行初始化,引起灾难性的错误。

image-20220224094541436

幸运的是g++编译器发现了这个错误并且提醒了我们。

但是并不是所有编译器都能发现这个错误,比如vs2010。

image-20220224094709856

堆对象的构造顺序

  • 当程序执行流到达new语句时创建对象
  • 使用new创建对象将自动触发构造函数的调用
1
2
3
4
5
6
7
8
9
10
11
int i = 0;
Test* a1 = new Test(i);

while( ++i < 10)
if(i % 2)
new Test(i);

if( i < 4)
new Test(*a1);
else
new Test(100);

image-20220224095236180

全局对象的构造顺序

  • 全局对象的构造顺序是不确定的!!!
  • 不同编译器使用不同的规则确定构造顺序

在进入main函数之前不存在程序执行流的概念。

这里我创建了四个全局对象分别位于t1.cpp,t2.cpp,t3.cpp,test.cpp内然后运行观察到构造函数执行顺序是t4->t1->t2->t3。

image-20220224095823173

再到windows下的vs2010下执行相同的代码。发现顺序又不一样。

image-20220224100057049

小结

  • 局部对象的构造顺序依赖于程序的执行流
  • 堆对象的构造顺序依赖于new的使用顺序
  • 全局对象的构造顺序是不确定的

如果构造函数抛出异常?

构造函数中拋出异常

  • 构造过程立即停止
  • 当前对象无法生成
  • 析构函数不会被调用
  • 对象所占用的空间立即收回(不会内存泄漏)

工程项目中的建议

  • 不要在构造函数中拋出异常
  • 当构造函数可能产生异常时 , 使用二阶构造模式

避免在析构函数中拋出异常 ! !
析构函数的异常将导致 :对象所使用的资源无法完全释放

对象的销毁

一般而言,需要的销毁的对象都应该做清理

解决方案:

  • 为每一个类都提供一个public的free函数
  • 对象不再需要时立即调用free函数进行清理

回想如果我们的电脑手机等软件出现问题
为什么重启可以解决90%的问题?
这是因为软件不稳定造成的,长时间运行出现的不稳定多半是内存泄漏引起的。

存在的问题:

  • free只是一个普通的函数,必须显示的调用
  • 对象销毁前没有做清理,很可能造成资源泄漏

那么我们的C++编译器是否能够自动调用某个特殊的函数进行对象的清理?
答案是可以的,析构函数。

析构函数

  • C++类中可以定义一个特殊的清理函数
    这个特殊的清理函数叫做析构函数
    析构函数的功能与构造函数相反

  • 定义:~ClassName()
    析构函数没有参数也没有返回值类型声明(没有参数自然就无法实现重载)
    析构函数在对象销毁时自动被调用

image-20220224103905244

注意上述代码中如果忘记释放堆上的内存delete pt;那么将不会执行析构函数

析构函数的定义准则
当类中自定义了构造函数,并且构造函数中使用了系统资源(如:内存申请,文件打开等),需要自定义析构函数。

析构函数的顺序

单个对象

单个对象创建时构造函数的调用顺序

  1. 调用父类的构造过程
  2. 调用成员变量的构造函数(如果成员也是类)(成员变量的调用顺序与声明顺序相同)
  3. 调用类自身的构造函数

析构函数与对应构造函数的调用顺序相反

  1. 调用类自身的析构函数
  2. 调用成员变量的析构函数
  3. 调用父类的析构过程

多个对象

多个对象析构顺序与构造顺序相反

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 <stdio.h>

class Member
{
const char* ms;
public:
Member(const char* s)
{
printf("Member(const char* s): %s\n", s);

ms = s;
}
~Member()
{
printf("~Member(): %s\n", ms);
}
};

class Test
{
Member mA;
Member mB;
public:
Test() : mB("mB"), mA("mA")//顺序与声明顺序一致,与初始化列表顺序无关,这样写误导人,
{
printf("Test()\n");
}
~Test()
{
printf("~Test()\n");
}
};

Member gA("gA");

int main()
{
Test t;

return 0;
}
1
2
3
4
5
6
7
8
9
10
fengyun@ubuntu:~/share$ g++ test.cpp -o test
fengyun@ubuntu:~/share$ ./test
Member(const char* s): gA
Member(const char* s): mA
Member(const char* s): mB
Test()
~Test()
~Member(): mB
~Member(): mA
~Member(): gA

关于栈对象和全局对象,类似于入栈和出栈的顺序,最后构造的对象最先析构!!

堆对象的析构发生在使用delete的时候,与delete的使用顺序相关!!

神秘的临时对象

image-20220224104420639

观察这一段程序,作者的程序意图是在main函数中执行Test t;希望t中mi的值被设置为0。

  • 在Test()中以0作为参数调用Test(int i)
  • 将成员变量mi的初始值设置为0

image-20220224104644249

但是执行观察运行结果:成员变量mi的值为随机值。

构造函数是一个特殊的函数

  • 是否可以直接调用?
  • 是否可以在构造函数中调用构造函数?
  • 直接调用构造函数的行为是什么?

答案:

  • 直接调用构造函数将产生一个临时对象
  • 临时对象的生命周期只有一条语句的时间
  • 临时对象的作用域只在一条语句中
  • 临时对象是C++中值的警惕的灰色地带

因此上面的程序问题在于第10行创建了一个临时对象,因此上述程序的构造函数等价于一个空的构造函数。

那我们如果对代码进行复用操作?

我们可以自己定义一个init函数。在不同的构造函数中调用init函数。

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
#include <stdio.h>

class Test {
int mi;

void init(int i)
{
mi = i;
}
public:
Test(int i) {
init(i);
}
Test() {
init(0);
}
void print() {
printf("mi = %d\n", mi);
}
};


int main()
{
Test t;

t.print();

return 0;
}
1
2
3
fengyun@ubuntu:~/share$ g++ test.cpp -o test
fengyun@ubuntu:~/share$ ./test
mi = 0

我们应该在编程里尽力地减少代码中临时对象的产生。
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
#include <stdio.h>

class Test
{
int mi;
public:
Test(int i)
{
printf("Test(int i) : %d\n", i);
mi = i;
}
Test(const Test& t)
{
printf("Test(const Test& t) : %d\n", t.mi);
mi = t.mi;
}
Test()
{
printf("Test()\n");
mi = 0;
}
int print()
{
printf("mi = %d\n", mi);
}
~Test()
{
printf("~Test()\n");
}
};

Test func()
{
return Test(20);
}

int main()
{
Test t = Test(10); // ==> Test t = 10;
Test tt = func(); // ==> Test tt = Test(20); ==> Test tt = 20;

t.print();
tt.print();

return 0;
}

39行Test t = Test(10);理论分析:1.生成临时对象。(调用构造函数)2.用临时对象初始化t对象(调用拷贝构造函数)
事实上上述代码等价于直接调用一次构造函数Test t = 10,编译器会进行优化仅调用一次构造函数使得程序的性能得到提升

40行Test tt = func()理论分析:1.生成临时对象并且返回 2.函数结束后销毁临时对象 3.用函数返回的临时对象初始化tt对象(调用拷贝构造函数)。
事实上,C++编译器会进行优化,尽量减少临时对象的产生。Test tt = func(); ==> Test tt = Test(20); ==> Test tt = 20;

1
2
3
4
5
6
7
8
fengyun@ubuntu:~/share$ g++ test.cpp -o test
fengyun@ubuntu:~/share$ ./test
Test(int i) : 10
Test(int i) : 20
mi = 10
mi = 20
~Test()
~Test()

关于类成员的疑问

问题:

1
2
3
4
Test::Test(const Test& t)
{
mi = t.mi;
}

为什么可以访问另一个对象的private成员。

成员函数和成员变量都是隶属于具体对象的吗?

  • 从面向对象的角度,对象由属性(成员变量)和方法(成员函数)构成
  • 从程序运行的角度,对象由数据和函数构成
    数据可以位于栈,堆,和全局数据区
    函数只能位于代码段(所有对象共享一套成员函数)

测试程序:

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 <stdio.h>

class Test
{
int mi;
public:
int mj;
Test(int i);
Test(const Test& t);
int getMi();
void print();
};

Test::Test(int i)
{
mi = i;
}

Test::Test(const Test& t)
{
mi = t.mi;
}

int Test::getMi()
{
return mi;
}

void Test::print()
{
printf("this = %p\n", this);
}

int main()
{
Test t1(1);
Test t2(2);
Test t3(3);

printf("t1.getMi() = %d\n", t1.getMi());
printf("&t1 = %p\n", &t1);
t1.print();

printf("t2.getMi() = %d\n", t2.getMi());
printf("&t2 = %p\n", &t2);
t2.print();

printf("t3.getMi() = %d\n", t3.getMi());
printf("&t3 = %p\n", &t3);
t3.print();

return 0;
}
1
2
3
4
5
6
7
8
9
10
11
fengyun@ubuntu:~/share$ g++ test.cpp -o test
fengyun@ubuntu:~/share$ ./test
t1.getMi() = 1
&t1 = 0x7ffc483ea880
this = 0x7ffc483ea880
t2.getMi() = 2
&t2 = 0x7ffc483ea888
this = 0x7ffc483ea888
t3.getMi() = 3
&t3 = 0x7ffc483ea890
this = 0x7ffc483ea890

结论

  • 每一个对象拥有自己独立的属性(成员变量)
  • 所有的对象共享类的方法(成员函数)
  • 方法(成员函数)能够直接访问任何所属类对象的属性(成员变量)
  • 方法中隐藏参数this用于指代当前对象

成员函数只有一套,可以访问任何所属类对象的属性。

类的静态成员变量

回顾类的成员变量属性

  • 通过对象名能够访问public 成员变量
  • 每个对象的成员变量都是专属的
  • 成员变量不能在对象之间共享

那么如果有以下的需求:

  • 统计在程序运行期间某个类的对象数目
  • 保证程序的安全性( 不能使用全局变量 )
  • 随时可以获取当前对象的数目

在 C+ + 中可以定义静态成员变量

  • 静态成员变量属于整个类所有
  • 静态成员变量的生命期不依赖于任何对象
  • 可以通过类名直接访问公有静态成员变量
  • 所有对象共享类的静态成员变量
  • 可以通过对象名访问公有静态成员变量

静态成员变量的特性

  • 在定义时直接通过 static 关键字修饰
  • 静态成员变量需要在类外单独分配空间
  • 静态成员变量在程序内部位于全局数据区

语 法 规 则 :
Type ClassName::VarName = value;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Test
{
private:
static int cCount;
public:
Test()
{
cCount++;
}
~Test()
{
--cCount;
}
int getCount()
{
return cCount;
}
};

类的静态成员函数

继续回顾之前的需求

  • 统计在程序运行期间某个类的对象数目【满足】
  • 保证程序的安全性( 不能使用全局变量 )【满足】
  • 随时可以获取当前对象的数目( Failure ) 【未满足】

我们可以将static int cCount;设为public,但是这样就不安全了

我们需要什么?

  • 不依赖对象就可以访问静态成员变量
  • 必须保证静态成员变量的安全性
  • 方便快捷的获取静态成员变量的值

在 C++中可以定义静态成员函数

  • 静态成员函数是类中特殊的成员函数
  • 静态成员函数属于整个类所有
  • 可以通过类名直接访问公有静态成员函数
  • 可以通过对象名访问公有静态成员函数

静态成员函数与普通成员函数的根本区别在于:普通成员函数有 this 指针,可以访问类中的任意成员;而静态成员函数没有 this 指针,只能访问静态成员(包括静态成员变量和静态成员函数)。

image-20220224141104629

最终解决方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Test
{
private:
static int cCount;
public:
Test()
{
cCount++;
}
~Test()
{
--cCount;
}
static int GetCount()
{
return cCount;
}
};

int Test::cCount = 0;

小结

  • 静态成员函数是类中特殊的成员函数
  • 静态成员函数没有隐藏的 this 参数
  • 静态成员函数可以通过类名直接访问
  • 静态成员函数只能直接访问静态成员变量( 函数 )

二阶构造模式

关于构造函数

  • 类的构造函数用于对象的初始化
  • 构造函数与类同名并且没有返回值
  • 构造函数在对象定义时自动被调用

那么引出了如下问题:

  1. 如何判断构造函数的执行结果?
  2. 在构造函数中执行 return 语句会发生什么?
  3. 构造函数执行结束是否意味着对象构造成功 ?

答案:

  1. 无法判断构造函数执行结果
  2. return会直接返回
  3. 并不意味对象构造成功

构造函数能决定的只是对象的初始状态 , 而不是对象的诞生 ! !

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
#include <stdio.h>

class Test
{
int mi;
int mj;
public:
Test(int i, int j)
{
mi = i;
return;
mj = j;
}
int getI()
{
return mi;
}
int getJ()
{
return mj;
}
};

int main()
{
Test t1(1, 2);

printf("t1.mi = %d\n", t1.getI());
printf("t1.mj = %d\n", t1.getJ());

return 0;
}

以上代码,构造函数中有return语句并且提前返回,我们运行结果如下:对象的初始化并不成功

1
2
3
fengyun@ubuntu:~/share$ ./test
t1.mi = 1
t1.mj = -1096823344

我们可以自己手动保存一个status变量,若构造函数执行成功,status置为true

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
#include <stdio.h>

class Test
{
int mi;
int mj;
bool mStatus;
public:
Test(int i, int j) : mStatus(false)
{
mi = i;

return;

mj = j;

mStatus = true;
}
int getI()
{
return mi;
}
int getJ()
{
return mj;
}
int status()
{
return mStatus;
}
};

int main()
{
Test t1(1, 2);

if( t1.status() )
{
printf("t1.mi = %d\n", t1.getI());
printf("t1.mj = %d\n", t1.getJ());

}

return 0;
}

但是这种解决方法仍然不够优美。

半成品对象的概念

  • 初始化操作不能按照预期完成而得到的对象
  • 半成品对象是合法的 C+ + 对象 , 也是 Bug 的主要来源

二阶构造

工程开发中的构造过程可分为

  • 资源无关的初始化操作
    不可能出现异常情况的操作
  • 需要使用系统资源的操作
    可能出现异常情况 , 如 : 内存申请 , 访问文件

image-20220224143921170

二阶构造实例一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class TwoPhaseCons {
private:
TwoPhaseCons(){ // 第一阶段构造函数
}
bool construct() { // 第二阶段构造函数
return true;
}
public:
static TwoPhaseCons* NewInstance(); // 对象创建函数
};

TwoPhaseCons* TwoPhaseCons::NewInstance() {
TwoPhaseCons* ret = new TwoPhaseCon();//创建一个对象,执行第一阶段

// 若第二阶段构造失败,返回NULL
if (!(ret && ret->construct())){
delete ret;
ret = NULL;
}
return ret;
}

注意13行TwoPhaseCons* ret = new TwoPhaseCon();,注意有个错误的说法:静态成员函数只可以访问静态成员变量/静态成员函数不能访问非静态成员。

静态函数没有默认的this对象指针。但是可以通过其他方式传入对象的地址,便可以在静态成员函数中访问到非静态成员函数。这种说法不够严密。仅仅是不能在静态成员函数中,使用this隐式或者显式调用非静态成员。因为静态函数不与对象绑定在一起,因此也不能声明成const的。

小结

  • 构造函数只能决定对象的初始化状态
  • 构造函数中初始化操作的失败不影响对象的诞生
  • 初始化不完全的半成品对象是 Bug 的重要来源
  • 二阶构造人为的将初始化过程分为两部分
  • 二阶构造能够确保创建的对象都是完整初始化的

友元的尴尬能力

友元的概念

  • 友元是 C++ 中的一种关系
  • 友元关系发生在函数与类之间或者类与类之间
  • 友元关系是单向的 ,不能传递

image-20220224155912209

友元的用法

  • 在类中以 friend 关键字声明友元
  • 类的友元可以是其它类或者具体函数
  • 友元不是类的一部分
  • 友元不受类中访问级别的限制
  • 友元可以直接访问具体类的所有成员
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
class Point
{
double x;
double y;
public:
Point(double x, double y)
{
this->x = x;
this->y = y;
}

double getX()
{
return x;
}

double getY()
{
return y;
}

//friend double func(Point& p1, Point& p2);
};

double func(Point& p1, Point& p2)//拥有友元的超能力
{
double ret = 0;

ret = (p2.y - p1.y) * (p2.y - p1.y) +
(p2.x - p1.x) * (p2.x - p1.x);

ret = sqrt(ret);

return ret;
}

注意29行ret = (p2.y - p1.y) * (p2.y - p1.y) + (p2.x - p1.x) * (p2.x - p1.x);我们C语言原先就是这样写的,
但是C++对对象的封装导致若要访问x,y必须调用getX(),getY()。
这么一个简单的语句要调用8次函数为人们所吐槽,因此C++设计者门为了兼容C语言而设计出了友元。

友元的尴尬

  • 友元是为了兼顾 C 语言的高效而诞生的
  • 友元直接破坏了面向对象的封装性(C++开发变为了C语言开发)
  • 友元在实际产品中的高效是得不偿失的
  • 友元在现代软件工程中已经逐渐被遗弃

Java,csharp诞生于C++,但是都没有友元这个能力,因此C++开发尽量减少友元的使用。

注意事项

  • 友元关系不具备传递性
  • 类的友元可以是其它类的成员函数
  • 类的友元可以是某个完整的类 —所有的成员函数都是友元

image-20220224161843649

小结

  • 友元是为了兼顾 c 语言的高效而诞生的
  • 友元直接破坏了面向对象的封装性
  • 友元关系不具备传递性
  • 类的友元可以是其它类的成员函数
  • 类的友元可以是某个完整的类

类中的函数重载

函数重载

  • 函数重载的本质为相互独立的不同函数
  • C+ + 中通过函数名和函数参数确定函数调用
  • 无法直接通过函数名得到重载函数的入口地址
  • 函数重载必然发生在同一作用域中

类中的成员函数可以进行重载

  • 构造函数的重载
  • 普通成员函数的重载
  • 静态成员函数的重载

万变不离其宗

  • 重载函数的本质为多个不同的函数
  • 函数名和参数列表唯一的标识(和有没有static关键字无关)
  • 函数重载必须发生在同一作用域中

重载的意义

  • 通过函数名对函数功能进行提示
  • 通过参数列表对函数用法进行提示
  • 扩展系统中已经存在的函数功能

扩展系统函数strcpy

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

char* strcpy(char* buf, const char* str, unsigned int n)
{
return strncpy(buf, str, n);
}

int main()
{
const char* s = "D.T.Software";
char buf[8] = {0};

//strcpy(buf, s);
strcpy(buf, s, sizeof(buf)-1);

printf("%s\n", buf);

return 0;
}

重载能够扩展系统中已经存在的函数功能 !

那么重载是否也能够扩展其它更多的功能 ?

image-20220224164943231

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
#include <stdio.h>

class Complex
{
int a;
int b;
public:
Complex(int a = 0, int b = 0)
{
this->a = a;
this->b = b;
}

int getA()
{
return a;
}

int getB()
{
return b;
}

friend Complex Add(const Complex& p1, const Complex& p2);
};

Complex Add(const Complex& p1, const Complex& p2)
{
Complex ret;

ret.a = p1.a + p2.a;
ret.b = p1.b + p2.b;

return ret;
}

int main()
{

Complex c1(1, 2);
Complex c2(3, 4);
Complex c3 = Add(c1, c2); // c1 + c2

printf("c3.a = %d, c3.b = %d\n", c3.getA(), c3.getB());

return 0;
}

操作符重载

  • C++ 中的重载能够扩展操作符的功能
  • 操作符的重载以函数的方式进行
  • 本 质 : 用特殊形式的函数扩展操作符的功能
  • 通过 operator 关键字可以定义特殊的函数
  • operator 的本质是通过函数重载操作符
1
2
3
4
5
6
7
Type operator Sign(const Type pi, const Type p2)
{
Type ret;

return ret;
}
//Sign为系统中预定义的操作符,如 + - * / 等

因此只需简单的修改上述的代码,就可以实现重载操作符

1
2
3
Complex Add(const Complex& p1, const Complex& p2)
//修改为
Complex operator + (const Complex& p1, const Complex& p2)
1
2
3
4
5
Complex c3 = Add(c1, c2);
//修改为
Complex c3 = operator + (c1, c2);
//继续修改为
Complex c3 = c1 + c2;

我们上述修改依然是基于友元的方法,那么如何不依赖友元重载操作符?

可以将操作符重载函数定义为类的成员函数,差别如下:

  • 比全局操作符重载函数少一个参数(左操作数)
  • 不需要依赖友元就可以完成操作符重载
  • 编译器优先在成员函数中寻找操作符重载函数
1
2
3
4
5
6
7
8
9
class Type
{
public:
Type operator Sign(const Type& p)
{
Type ret;
return ret;
}
}
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 <stdio.h>

class Complex
{
int a;
int b;
public:
Complex(int a = 0, int b = 0)
{
this->a = a;
this->b = b;
}

int getA()
{
return a;
}

int getB()
{
return b;
}

Complex operator + (const Complex& p)
{
Complex ret;
printf("Complex operator + (const Complex& p)\n");
ret.a = this->a + p.a;
ret.b = this->b + p.b;

return ret;
}

friend Complex operator + (const Complex& p1, const Complex& p2);
};

Complex operator + (const Complex& p1, const Complex& p2)
{
Complex ret;
printf("Complex operator + (const Complex& p1, const Complex& p2)\n");
ret.a = p1.a + p2.a;
ret.b = p1.b + p2.b;

return ret;
}

int main()
{

Complex c1(1, 2);
Complex c2(3, 4);
Complex c3 = c1 + c2; // c1.operator + (c2)

printf("c3.a = %d, c3.b = %d\n", c3.getA(), c3.getB());

return 0;
}

小结

  • 操作符重载是 C+ + 的强大特性之一
  • 操作符重载的本质是通过函数扩展操作符的功能
  • operator 关键字是实现操作符重载的关键
  • 操作符重载遵循相同的函数重载规则
  • 全局函数和成员函数都可以实现对操作符的重载

注意事项:

  • C+ + 规定赋值操作符( = )只能重载为成员函数
  • 操作符重载不能改变原操作符的优先级
  • 操作符重载不能改变操作数的个数
  • 操作符重载不应改变操作符的原有语义