OOP 相关
# Lecture10-OOP
https://spricoder.github.io/2020/07/01/2020-C-plus-plus-advanced-programming/C++-OOP/2020-C-plus-plus-advanced-programming-C++ 类的封装 /#6 - 类的移动构造函数
- encapsulation
- 减少类之间的耦合
- 类内部的结构可以自由的进行修改
- 对成员进行控制
- 对代码的理解性更好
- information hidding:不需要知道如何初始化,只需要使用提供的接口
- Cfront 第一个 C++ 的编译器,转为 C
- 基于对象:没有继承
- 面向对象:封装、继承、多态
# 类
类中包含成员变量、成员函数
头文件、源文件:C++ 是一个个编译单元进行编译,所以需要提前知道其他编译单元的相关信息(存储在头文件中),只需要知道声明,不需要知道具体定义,减少编译复杂度
- 如果直接将函数定义直接放在头文件里,会建议 compiler 将其作为 inline 函数进行编译。
- 如果函数长度很长的话,反复调用的函数调用时间就会占比很小,而相反的话则会很大。
- 随便使用内联函数可能是的代码很烂:get 和 set 函数我们选择使用 inline 方式
- 代码长度不超过 10 行,不包含 for、switch 等语句。
# 构造函数
# 构造函数
当类中未提供构造函数的时候,编译系统会提供默认构造函数。
程序员无论提供有参数的还是无参数的构造函数,编译系统都不再提供,防止干扰程序员本身的意思。
成员变量如果是成员对象,则总是会初始化的,需要为成员对象设置构造函数
# 变量的初始化
- 全局变量和静态变量,未初始化,默认为 0
- 局部变量、成员变量,未初始化,默认为不确定的值
- 如果没有指定 c++ 默认初始化,则各种变量都会有不确定的值
- 编译系统提供的默认构造函数不会对成员变量进行处理,主要功能是完成对象的初始化,创建标识符,开辟内存空间,最后再根据传入的参数或者默认值进行对数据的处理。
- 构造函数可定义为
private
,避免在其他代码中创建该对象,所以只能通过类内部的方法进行创建,而类内部的方法是我自己写的,因此可以接管对象的创建,例如保证单例,或者保证只有十个对象创建
按照参数列表来对应构造函数
# 成员初始化表(构造函数初始化成员变量的一种方法)
https://blog.csdn.net/u010853261/article/details/85036025
# C++ 构造函数的初始化列表定义
C++ 的构造函数与其他函数不同,构造函数除了有名字,参数列表和函数体之外,还可以有初始化列表,初始化列表以冒号开头,后跟一系列以逗号分隔的初始化字段。
比如下面的例子:
class Foo | |
{ | |
private: | |
string name ; | |
int id ; | |
public: | |
Foo(string s, int i):name(s), id(i){} ; // 初始化列表 | |
}; |
- 构造函数的补充
- 构造函数:先开辟空间并赋默认值
- 成员初始化表:开辟空间的时候就赋值
- 执行:(常量和引用的声明和定义要放在一起,只能通过这个方法来完成)
- 先于构造函数执行
- 按类数据成员声明次序:下面的例子中先 x 再 y 再 z
static const
: 常量数字,这个是可以在类内部进行初始化 (static const a = 1;
)
class A{ | |
// 非静态成员可以初始化 | |
int x; | |
const int y; | |
int& z;// 引用 | |
public: | |
// 签名的冒号后面,用变量 (值) 来进行初始化,这就是初始化表 | |
A(): y(1),z(x),x(0){ | |
x = 100;// 赋值 | |
} | |
}; |
- 减轻 Compiler 负担:
- 正常构造函数中赋值
x = 100
:首先对象构造的时候进行了赋值,之后再次进行了赋值,共计 2 次 - 成员初始化表的时候,只进行了赋值一次。
- 初始化顺序问题:先执行 p,再执行 size 有问题,按照字面序进行。
class CString{ | |
char *p; | |
int size; | |
public: | |
CString(int x):size(x),p(new char[size]){} | |
}; |
必须放在成员初始化表中:
- 常量成员,因为常量只能初始化不能赋值,所以必须放在初始化列表里面
- 引用类型,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面
- 没有默认构造函数的 class type,因为使用初始化列表可以不必调用默认构造函数来初始化,而是直接调用拷贝构造函数初始化。
# 析构函数
程序员负责资源的申请和释放
类的析构函数,它是类的一个成员函数,名字由波浪号加类名构成,是执行与构造函数相反的操作:释放对象使用的资源,并销毁非 static 成员。
同样的,我们来看看析构函数的几个特点:
- 函数名是在类名前加上~,无参数且无返回值。
- 一个类只能有且有一个析构函数,如果没有显式的定义,系统会生成一个缺省的析构函数(合成析构函数)。
- 析构函数不能重载。每有一次构造函数的调用就会有一次析构函数的调用。
声明为 private
系统无法调用析构函数,因为是自动消亡的,内存分配在栈中,离开作用域就会自动消亡
通过将对象的析构函数定义为
**private**
,强制在堆上分配内存,场景:栈的内存有限,对象的内存很大better solution:这种方法也能够将
p
指针重新定义为空指针,更好
GC 垃圾回收
- 存在效率障碍,发生时间不确定
- 存在不能使用 GC 的场合
- 只能回收内存,不能回收文件操作的句柄等
finalize
- 不能由程序员自己控制
**RAII Resource Acquisition Is Initialization **
- 什么时候获取什么时候释放都是确定的
- 对象获得的资源都是要在析构函数中释放的
- 栈上的内存资源自动释放,堆上的内存资源需要通过析构函数释放
# 拷贝构造函数 copy constructor—— 一种特殊的构造函数
拷贝构造函数是来复制对象用的。一个对象,多个副本,副本之间是独立的关系。
将地址传递和值传递统一起来,归根结底还是传递的是 "值"(地址也是值,只不过通过它可以找到另一个值)!
- 相同类型的类对象是通过拷贝构造函数来完成整个复制过程:自动调用:创建对象时,用一同类的对象对其初始化的时候进行调用。
- 默认拷贝构造函数
- 逐个成员初始化 (member-wise initialization)
- 对于对象成员,该定义是递归的
- 什么时候需要拷贝构造函数:
- 赋值拷贝构造
- 传参进行拷贝
- 返回值进行拷贝
- 拷贝构造函数私有:目的是让编译器不能调用拷贝构造函数,防止对象按值传递,只能引用传递 (对象比较大)
# 拷贝函数的使用情况以及定义
// 赋值拷贝构造 | |
A a; | |
A b=a; | |
// 传参进行拷贝 | |
f(A a){} | |
A b; | |
f(b); | |
// 返回值进行拷贝 | |
A f(){ | |
A a; | |
return a; | |
} | |
f(); | |
// 拷贝构造函数 | |
public: | |
//const 避免出现修改 | |
A(const A& a);// 一定要写引用,不然就递归调用了 |
为什么对象是一个引用类型,而不是值传递?
不然会出现循环拷贝问题:如果没有引用的话,传参则会拷贝,那么就会出现循环拷贝,也就是防止递归引用。
# 拷贝构造函数的深 / 浅拷贝
# 默认拷贝构造函数
“默认拷贝构造函数”,这个构造函数很简单,仅仅使用 “老对象” 的数据成员的值对 “新对象” 的数据成员一一进行赋值,它一般具有以下形式:
Rect::Rect(const Rect&r){ | |
width = r.width; | |
height = r.height; | |
} |
默认拷贝构造函数会在处理静态数据成员和指针数据成员的时候出现错误。
# 深拷贝 / 浅拷贝
深拷贝和浅拷贝可以简单理解为:如果一个类拥有资源,当这个类的对象发生复制过程的时候,资源重新分配,这个过程就是深拷贝,反之,没有重新分配资源,就是浅拷贝。
<img src="https://quasdo.oss-cn-hangzhou.aliyuncs.com/img/image-20221101192555740.png" alt="image-20221101192555740" style="zoom:200%;" />
- 原来 S1 和 S2 两个指针都指向 "abcd", 但是随着 S1 的归还,S2 就变成了一个空指针了。
- 此时我们通过深拷贝完成拷贝
- 没有深拷贝需求的时候,使用编译器默认构造函数即可
# 拷贝构造函数的初始化问题
“接管编译器行为”
包含成员对象的类
- 默认拷贝构造函数:调用成员对象的拷贝构造函数
- 自定义拷贝构造函数:调用成员对象的默认构造函数:程序员如果接管这件事情,则编译器不再负责任何默认参数。
拷贝函数的拷贝过程没有处理静态数据成员
默认拷贝构造函数:
逐个成员初始化
对于对象成员,该定义是递归的
https://blog.csdn.net/sinat_39370511/article/details/91981033
# 移动构造函数 move constructor
官方文档:
https://en.cppreference.com/w/cpp/language/move_constructor
一些参考:
https://zhuanlan.zhihu.com/p/365412262
https://blog.csdn.net/sinat_25394043/article/details/78728504
所谓移动语义(Move 语义),指的就是以移动而非深拷贝的方式初始化含有指针成员的类对象。对于程序执行过程中产生的临时对象,往往只用于传递数据(没有其它的用处),并且会很快会被销毁。因此在使用临时对象初始化新对象时,我们可以将其包含的指针成员指向的内存资源直接移给新对象所有,无需再新拷贝一份,这大大提高了初始化的执行效率。(看上去,原来的成员没有访问资源的权限了?
从一个对象拷贝到了另一个对象。
左值和右值
左值:赋值操作符左边的值。是可以赋值的,通常是一个变量
右值:赋值操作符右边的值。是一个值,通常是一个常数、表达式、函数调用
能出现在赋值号左边的表达式称为 “左值”,不能出现在赋值号左边的表达式称为 “右值”。一般来说,左值是可以取地址的,右值则不可以。
非 const 的变量都是左值。函数调用的返回值若不是引用,则该函数调用就是右值。一般的 “引用” 都是引用变量的,而变量是左值,因此它们都是 “左值引用”。
C++11 新增了一种引用,可以引用右值,因而称为 “右值引用”。无名的临时变量不能出现在赋值号左边,因而是右值。右值引用就可以引用无名的临时变量。
Const 只能被绑定到右值上
- 不可以写成
int &x = 5
- 为什么不可以对非 const 引用绑定一个右值?可能会导致可以修改临时变量的值,不允许被修改。
- 不可以写成
移动构造函数:直接将对应的右值移动过来 (我们已经将 vector 和 String 进行了是此岸)
&& 是右值引用,不会被左值调用。
五删原则:拷贝构造、拷贝赋值、析构函数、移动构造、移动复制
- 提供上面的 5 个函数之一,则需要自己提供默认函数
# 临时变量与普通变量
临时变量为右值,优先匹配移动构造函数【临时变量不能绑定到左值,改变临时变量的值不合理】;普通变量为左值,优先匹配拷贝构造函数。
const A ( & A);
https://blog.csdn.net/weixin_45799835/article/details/105780627
# 动态对象
在堆上创建
引入 new 和 delete 操作符,除了可以分配内存,还可以调用构造函数,消除对象时,能归还内存,还可以调用析构函数。
malloc 不调用构造函数
new 可重载
new 可以重载:开辟内存(new 的第一步)可以重载,调用构造函数初始化和返回地址赋值(new 的二三步)不能重载。
delete [] 告诉编译器,此指针带了一个头,存储了别的信息,有偏移量。
# 创建对象
new(new 的时候一般带指针)
- 使用原始类型
- 使用类类型
Syntax 语法
- 原始类型:
type* ptrName = new type;
- 使用类类型:
type* ptrName = new type(params);
- 原始类型:
- 注意:这是没有变量名字的物体【堆上的对象,只有通过指针去访问对象】;指针的大小都是一样的,无论数据有多大。
# 对象删除
delete:
- 唤起指向物体的指针
- 处理原始类型或类类型
语法:
delete ptrName;
注意: 删除之后,要将指针置为空指针,这样子之后可以继续使用,避免意外的引用对象,如果指针没有修改的话,可能是一个悬挂指针 (有可能出现段错误等等)
dangling pointer 悬垂指针
double free 两次删除
如果 delete 后指向 null,没有任何作用,可以防止很多的内存安全问题。这样写可以提高程序的鲁棒性。
public void(void * p){
delete p;
}
// 如果没有办法确定 p 的类型,那么只会释放内存,但是不会调用析构函数
// 对于 cpp 这种编译语言来说,类型的声明是非常重要的
# 动态对象数组
A *p; | |
p = new A[100]; | |
//p = new A; | |
// 以上两种写法返回的都是 A 的指针 | |
delete p[];//[] 不能够省略,省略之后不知道指向的是一个 A,还是一个数组 |
在指针前存储 4 个字节,存储了数据类型,如果没有 [],就不会去访问 4 个字节。那么后面的对象都没有调用析构函数,没有内存释放,造成内存泄漏。
同时,会出现段错误。段错误是指访问的内存超出了系统给这个程序所设定的内存空间,例如访问了不存在的内存地址、访问了系统保护的内存地址、访问了只读的内存地址等等情况。
int *p; | |
p = new int[100]; | |
delete p; |
以上这段代码没有问题,对于内置的类型 int,不需要存储 4 个字节,也不需要调用析构函数。p 是一块完整的内存。
但是,为了简单起见,new [] 与 delete[] 搭配使用。
注意
在堆上分配的内存默认不进行初始化
int *p1 = new int[5];
默认不进行初始化int *p2 = new int[5]();
进行默认初始化int *p2 = new int[5]{0,1,2,3,4}
: 进行显式对应函数初始化
隐式初始化 / 显式初始化
显式初始化即为手工给予初值,否则为隐式初始化,将内容设置为默认值。
自动变量在运行时进入函数的时候,才进行分配空间赋值。非自动变量会自隐式清零,而自动变量是不会自隐式清零的。若没有在定义变量时显式初始化,未赋值前,该变量的内容是不确定值。
int F1; // 初始化为 0 | |
string F2; // 初始化为 null | |
int F3 = 25; // 初始化为 25 | |
string F4 = "abcd"; // 初始化为 “abcd” |
# 动态 2D 数组
# 创建
# 删除
先 delete char*,再 delete char**
先 delete 三个粉红块块,再 delete 一个粉红块块
多维数组很少用现在这种方式做,都是用一维数组模拟多维数组。
# Const 成员
const 是程序中最稳定的部分。
- 初始化放在构造函数的成员初始化表中进行:
- 常量在初始化的时候必须被给值,而不是赋值,所以不能写在构造函数内
- 所以我们通过初始化表的方式完成。
- 不同的对象,可以有不同的 const 成员变量的值。对象一旦有了,在对象的生命周期内不变。不同的对象可以有不一样的值。A (1); A (2);
- 所有的对象共享一个 static const: 编译器内的常量,所有的对象都是一样的,最好在定义的地方进行初始化。
静态成员变量一般在类外进行定义。
class A{ | |
int x,y; | |
public: | |
A(int x1, int y1); | |
void f();// 如何判断不是 const? | |
void show() const;// 前后要保证一致,const 在后面,在不改变成员变量的函数后面增加 const。 | |
}; | |
void A::f(){x = 1; y = 1;} | |
// 编译器怎么能发现不是 const 的?转化为防止变量被赋值,见下面,所以 const 指针不能修改 | |
void f(A * const this); | |
// 上面的函数相当于这个 | |
void A::show() const | |
{cout <<x << y;} | |
//const 改变参数声明 | |
void show(const A* const this); | |
// 上面的函数相当于这个,第一个 const 表示指向对象常量,后一个 const 表示指针本身是常量 | |
const A a(0,0);// 声明了一个常对象 a: 这个对象是不可以修改的 | |
a.f(); // 错误,常对象无法调用非常方法 | |
a.show();// 正确 |
我们将不修改对象内变量的值的时候,将对应方法声明为 const,如 const A a(0,0);
const 成员函数,在声明编译的函数后面,增加关键字 const。 void show(); const
当对象声明为 const 的时候,只能调用 const 成员函数;类似于对象声明为 static 时,只能调用 static 函数。
void f(A * const this); | |
// 指针不可变 | |
void show(const A * const this); | |
//this 指针所指向的内存的内容不可变 |
const 有 就近原则
,const 靠近 this,this 不可变,const 靠近 A,A 的内容不可变
class A{ | |
int a; | |
int & indirect_int; | |
public: | |
A():indirect_int(*new int){ ... } | |
~A() { | |
delete &indirect_int; | |
} | |
void f() const{ | |
// 只要不是直接修改变量的值就 OK | |
// 引用本身是不能修改的,所以编译器认为没问题 | |
indirect_int++;// 只是指向的内容发生了变化 | |
} | |
}; |
int & —— 变量的引用:
- 这里的 & 不是取地址符号,而是引用符号,引用是 C++ 对 C 的一个重要补充。
- 声明引用时必须指定它代表的是哪一个变量,即对它初始化。
- 引用与其所代表的变量共享同一内存单元,系统并不为引用另外分配存储单元;
- 对引用的初始化,可以是一个变量名,也可以是另一个引用。
- 引用初始化后不能再被重新声明为另一变量的别名
对象外的内存与类没有什么关系。indirect_int 可以 ++。
如何解决用 a 做 indirect_int 初始化,并且改变 indirect_int 的引用对象的值:
- 关键词 mutable: 表示成员可以在
const
中进行修改,而不是用间接的方式来做。给程序员一些自由度,语言设计逻辑自洽即可。 - 去掉 const 转换:
(const_cast)<A*>(this)->x
转换后可以修改原来的成员。利用原有的语法实现 mutable,非常优雅。
# 静态成员
类刻画了一组具有相同属性的对象。
静态成员变量放在类外的.cpp 文件中定义,只能定义一次。
const static 是个例外,是个符号,不在全局数据区,在声明的时候初始化,不在初始化表种初始化。
- f 只能存取静态成员变量,调用静态成员函数。
- 类访问控制(public\private\static...):在类上直接访问只能是静态成员变量
A::f (); 类似于 std::cout;
// 单例模式 | |
class singleton{ | |
private:// 构造函数外部不可以使用,可以控制类的对象的个数 | |
singleton(){} | |
singleton(const singleton &); | |
public: | |
static singleton *instance() { | |
// 每次访问的时候判断是否为 null,在这个名空间下,可以调用 new 创建对象 | |
return m_instance == NULL? m_instance = new singleton: m_instance; | |
} | |
static void destroy() { delete m_instance; m_instance = NULL; } | |
private: | |
static singleton *m_instance;// 保存对象的指针也是 static 的,指针指向本身 | |
}; | |
singleton *singleton::m_instance= NULL;// 初始化 |
# 友元
# 简介
- 类外部不能访问该类的 private 成员
- 通过该类的 public 方法
- 会降低对 private 成员的访问效率,缺乏灵活性:如果使用 public 方法使用这些成员则是实行调用函数,降低调用效率,消耗时间
- 例:矩阵类 (Matrix)、向量类 (Vector) 和全局函数 (multiply),全局函数实现矩阵和向量相乘
- 隐藏细节、保持一致性
- 友元是数据保护和访问效率的折衷方案
- 友元可以访问 private 和 protected 的成员
# matrix vector
//Matrix | |
class Matrix{ | |
int *p_data;// 逻辑二维,一维存储 | |
int lin,col; | |
public: | |
Matrix(int l, int c){ | |
lin = l; | |
col = c; | |
p_data = new int[lin*col]; | |
} | |
~Matrix(){ | |
delete []p_data; | |
} | |
int &element(int i, int j){ | |
return *(p_data+i*col+j);// 指针类型的偏移是根据指针指向对象的类型 | |
} | |
void dimension(int &l, int &c){ | |
l = lin; | |
c = col; | |
} | |
void display(){ | |
int *p=p_data; | |
for (int i=0; i<lin; i++){ | |
for (int j=0; j<col; j++){ | |
cout << *p << ' '; | |
p++; | |
} | |
cout << endl; | |
} | |
}; | |
}; | |
//Vector | |
class Vector{ | |
int *p_data; | |
int num; | |
public: | |
Vector(int n){ | |
num = n; | |
p_data = new int[num]; | |
} | |
~Vector(){ | |
delete []p_data; | |
} | |
int &element(int i) | |
{ return p_data[i]; } | |
void dimension(int &n) | |
{ n = num; } | |
void display(){ | |
int *p=p_data; | |
for (int i=0; i<num; i++,p++) | |
cout << *p << ' '; | |
cout << endl; | |
} | |
}; | |
// 实现 矩阵和一个向量进行计算,效率比较 | |
void multiply(Matrix &m, Vector &v, Vector &r){ | |
int lin, col; | |
m.dimension(lin,col); | |
for (int i=0; i<lin; i++){ | |
r.element(i) = 0; | |
for (int j=0; j<col; j++) | |
r.element(i) += m.element(i,j)*v.element(j);// 这里的调用效率会比较低 | |
} | |
} | |
void main(){ | |
Matrix m(10,5); | |
Vector v(5); | |
Vector r(10); | |
multiply(m,v,r); | |
m.display(); | |
v.display(); | |
r.display(); | |
} |
没有 classC,可以声明友元吗?不可以,因为需要知道内存空间【先声明后使用】
没有 classB,可以声明友元吗?可以
第一种情况:friend class B:
- 编译器会寻找有没有类 B
- 如果没有则会引入一个 B
第二种情况:friend B
- 省略关键字的时候不会引入 B,如果没有 B 会报错模板类
- 但是这种形式常用于模板类 (T 或者 typedef 的时候来写)
需要前向声明:在 class Matrix 前声明 class Vector
友元不具有传递性:
不能说 A 是 B 的友元,B 是 C 的友元就可以得出 A 是 C 的友元
友元必须显式声明
# 相互引用的两个类
如果两个类相互引用可能是出现了一些结构上的问题。一起用和只用一个类,拆开来没什么意思。
完整的定义必须在声明之后,必然有一个类缺少完整的声明。
复用和维护的考虑。(继承……,有派生和继承相互引用就有意义)
如果每一个数据都可以 get 和 set,并没有做好封装,相当于每一个 private 都是 public 的。所以要设置好类型的
# 迪米特法则 LoD
- Each unit should have only limited knowledge about other units: only units "closely" related to the current unit.
- Each unit should only talk to its friends; don't talk to strangers.
- Only talk to your immediate friends.
迪米特法则 (Law of Demeter) 又叫做最少知识原则,也就是说,一个对象应当对其他对象尽可能少的了解。不和陌生人说话。英文简写为: LoD。
迪米特法则的目的在于降低类之间的耦合。由于每个类尽量减少对其他类的依赖,因此,很容易使得系统的功能模块功能独立,相互之间不存在(或很少有)依赖关系。
迪米特法则不希望类之间建立直接的联系。如果真的有需要建立联系,也希望能通过它的友元类来转达。因此,应用迪米特法则有可能造成的一个后果就是:系统中存在大量的中介类,这些类之所以存在完全是为了传递类之间的相互调用关系 —— 这在一定程度上增加了系统的复杂度。