C++面向对象(一)封装、继承和多态

类不是实体,对象是实体

成员变量(filed)属于对象

成员函数(member function)属于类

Big Three(构造函数,拷贝构造,拷贝赋值)

构造函数

初始化列表

列表初始化(initialize list)仅对成员变量初始化。

在构造函数里对成员变量初始化则为先初始化(默认)后赋值,故所有成员变量必须要有默认的初始化方法(成员变量包含其他类但该类没有默认构造函数则会报错)。构造函数无法主动调用。

尽量使用列表初始化

1
2
3
4
5
6
class A{
public:
int a;
A(int i):a(i){}; //initialize list
// A(int i):{a=i;}
};

拷贝构造和拷贝赋值

浅拷贝和深拷贝有什么区别

浅拷贝为字节流的拷贝,对于指针来说,通过浅拷贝会使新对象和旧对象指向同一块内存(类默认的拷贝构造和拷贝赋值)

深拷贝在复制时为指针分配新的内存,新对象和旧对象有各自独立的空间。

对于成员变量含有指针的类,尽量自己实现拷贝构造和拷贝赋值,防止不同对象的成员变量的指针指向同一块内存

代码举例
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
class Test
{
public:
Test() : ptr(0)
{
ptr = new char[1];
ptr[0] = '\0';
cout << "call Test()" << endl;
};

Test(const char *c_ptr)
{
if (c_ptr)
{
ptr = new char[strlen(c_ptr) + 1];
}
else
{
ptr = new char[1];
ptr[0] = '\0';
}
cout << "call Test(const char *c_ptr)" << endl;
};

Test(Test &test)
{
ptr = new char[strlen(test.ptr) + 1];
strcpy(ptr, test.ptr);
cout << "call Test(Test &test)" << endl;
};

Test &operator=(Test &test)
{
if (this == &test) //1.防止无意义拷贝 2.防止在拷贝自身前清空ptr内容
return *this;
delete[] ptr;
ptr = new char[strlen(test.ptr) + 1];
strcpy(ptr, test.ptr);
cout << "call copy operator=" << endl;
return *this;
}

private:
char *ptr;
};

int main()
{
Test test; //构造函数,call Test()
Test test1("123"); //拷贝构造,call Test(const char *c_ptr)
Test test2 = test1; //拷贝赋值,call Test(Test &test)
Test test3(test1); //拷贝赋值,call Test(Test &test)
test3 = test2; //赋值,call copy operator=
}

封装

封装从字面上来理解就是包装的意思,专业点就是信息隐藏,是指利用抽象将数据和基于数据的操作封装在一起,使其构成一个不可分割的独立实体

数据被保护在类的内部,尽可能地隐藏内部的实现细节,只保留一些对外接口使之与外部发生联系。

其他对象只能通过已经授权的操作来与这个封装的对象进行交互。也就是说用户是无需知道对象内部的细节(当然也无从知道),但可以通过该对象对外的提供的接口来访问该对象。

使用封装有 4 大好处:

  • 1、良好的封装能够减少耦合。
  • 2、类内部的结构可以自由修改。
  • 3、可以对成员进行更精确的控制。
  • 4、隐藏信息,实现细节。

继承

为什么需要继承?

如果仅仅只有两三个类,每个类的属性和方法很有限的情况下确实没必要实现继承,但事情并非如此,事实上一个系统中往往有很多个类并且有着很多相似之处,比如猫和狗同属动物,或者学生和老师同属人。各个类可能又有很多个相同的属性和方法,这样的话如果每个类都重新写不仅代码显得很乱,代码工作量也很大。

这时继承的优势就出来了:可以直接使用父类的属性和方法,自己也可以有自己新的属性和方法满足拓展,父类的方法如果自己有需求更改也可以重写。这样使用继承不仅大大的减少了代码量,也使得代码结构更加清晰可见

访问属性

访问限制符\访问位置 当前类 子类 类外
public 可以 可以 可以
protected 可以 可以 不可以
private 可以 不可以 不可以

重载、重写和覆盖

重载(overloading)

在同一个作用域中,函数名相同,但参数列表(包括参数的类型、个数或顺序)不同,形成的多个函数称为函数重载

重写(overriding)

派生类中,定义了一个与基类中同名且参数列表完全相同的函数,并通过 virtual 声明基类函数实现动态多态。(虚函数实现)

覆盖(Hiding)

派生类中,定义了一个与基类同名但参数列表不同的函数时,基类的函数被隐藏(覆盖)。函数覆盖不是动态多态的一部分,与重写不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class A{
public:
int i;
A(int a):i(a){};
void print(){cout<<"A::print()"<<endl;};
void print(int a){cout<<"A::print(int i)"<<endl;};
};

class B:public A{
public:
B(int a):A(a){};
void print(){cout<<"B::print()"<<endl;};
};

int main(){
B b(4);
b.print(2); //ERROR, cant found print(int a) in A
}

多态

概念

不同的对象对于同一消息作出不同的响应。子类在继承父类后可以设计自己的版本,在运行时动态选择调用哪个版本实现

静态绑定和动态绑定

在面向对象编程中,静态绑定和动态绑定是两种不同的函数调用机制。

静态绑定(Static Binding),也称为早期绑定或编译期绑定,是指在程序编译时就将函数调用与函数实现绑定起来,而不考虑对象的实际类型。这种绑定是通过函数的名称和参数列表来实现的。在静态绑定中,编译器会在编译期间确定调用哪个函数,而不是在运行时确定。静态绑定通常适用于非虚函数的调用,因为非虚函数的调用是在编译期间就可以确定的。

动态绑定(Dynamic Binding),也称为晚期绑定或运行时绑定,是指在程序运行时根据对象的实际类型来决定调用哪个函数。这种绑定是通过虚函数来实现的。在动态绑定中,编译器会在运行时确定调用哪个函数,而不是在编译期间确定。动态绑定适用于需要实现多态性的情况,可以让基类指针或引用调用派生类中的函数实现,实现运行时多态性

函数重载或函数模版(编译时多态)

虚函数(运行时多态)

作用
  • 实现动态多态性(Runtime Polymorphism):通过使用虚函数,可以在运行时动态地确定调用的是基类函数还是派生类函数,实现多态性。例如,如果我们有一个指向基类对象的指针,我们可以使用虚函数来调用派生类中的适当函数。
  • 支持运行时类型识别(RTTI):通过使用虚函数和类型信息(type information),可以在运行时确定对象的实际类型,从而实现更加灵活的代码设计。
  • 简化代码维护:使用虚函数可以将代码的实现细节从类的使用者中分离出来,使得修改基类的实现对派生类的影响最小。
实现

对于有虚函数的类,其对象会包含指向vtable的vptr指针(vtable中为所有虚函数的地址)。在运行时确定对象类型,来选取相应的vptr,进而找到vptr指向的vtable的虚函数的具体函数地址

要求
  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
class Base
{
public:
Base() : i(0) {}
virtual void print()
{
cout << "Base print" << endl;
}

private:
int i;
};

class Derived : public Base
{
public:
virtual void print()
{
cout << "Derived print" << endl;
}
};

int main()
{
Base *test = new Derived(); //向上造型(upcasting)指将派生类的引用或指针转化为父类的引用或指针
test->print(); //Derived print
return 0;
}
其他

如果派生类没有覆盖基类的虚函数,则派生类会直接继承基类中的版本

如果类存在虚函数,则需将析构函数设为虚函数,否则在基类指针调用析构时只能运行基类的析构

友元

类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数称为它的友元

1
2
3
4
5
6
7
class Sales_data{
friend Sales_data add(const Sales_data&, const Sales_data&);
public:
//...
private:
//...
};

一般来说,最好在类定义开始货结束前的集中位置声明友元

友元的声明只指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么就必须在友元声明外再专门对函数进行一次声明

相同类的对象互为友元