文章目录
面向对象编程
内联函数(inline)
inline:在类内定义的函数,都默认为内联函数,类外的函数,则需要在返回类型前添加inline。
inline只是建议编译构建函数时,构建为内联的,具体是不是则由编译决定。
inline作用:空间换时间,加快程序的运行速度。
class Complex {
public:
Complex &operator+=(const Complex &); // 操作符重载
......
};
inline Complex &Complex::operator+=(const Complex &r) {
return __doapl(this, r);
}
构造函数(constructor)
一般使用初始化列表(initialization list)来初始化参数。
没有在初始化列表中的成员参数会被隐式初始化
构造函数一般是声明为public,供他人创建,也有声明为 private 的,例如下面的单例模式:
class{
public:
static A& getInstance(){//调用函数时,才创建对象
static A a;
return a;
}
private:
A(){}
};
在单例模式中,外部无法显式调用构造函数,没有构造函数则无法创建对象,因此将 getInstance() 声明为静态的,可通过类来直接调用。
参数的值传递和引用传递
int getReal() const; // const:只读,不能修改对象的数据
int getImag() const;
Complex &operator+=(const Complex &);
ostream &operator<<(ostream &os, const Complex &x) { // 在全局定义
return os << '(' << real(x) << ", " << imag(x) << ')';
}
一般来说,引用传递效率快于值传递,为了提高效率,我们优先使用引用传递参数,避免了参数的复制。若不希望在函数体内对输入参数进行修改,应使用
const
修饰输入参数。若函数的返回值是临时变量,则只能通过值传递返回。
什么是值传递,什么是引用传递?
举个比喻性例子:现在有一个包裹,并且需要把它传递出去。
- 值传递:把包裹里面的东西全部拷贝到一个临时包裹,整个传过去,无论多大都一起传过去
- 引用传递:把包裹的地址传过去,告诉对方包裹在哪里。这里知道了地址,就知道了包裹有什么东西
区别:
-
对于大包裹来说,值传递的速度慢,但是引用传递则没有影响,因为只是传了一个地址值,因此引用传递的速度一般快于值传递
-
(传递的包裹或地址,都是对方的需要用到的一个参数)
值传递:当对参数进行修改时,不影响原包裹
引用传递:当对参数进行修改时,影响原包裹
- const:只读
引用传递比值传递快,当我只是想要传递速度快,而不想对方因为修改(编译报错)而影响我原来的包裹时,只需加入 const 关键字
PS:参数传递首先考虑引用。
● 问题:什么时候不能使用引用传递?
某些局部变量。比如在某函数创建的局部变量,要将该变量传递出去,只能用值传递。因为当所在函数生命周期结束(执行完毕)后,该变量已经不存在,无法将其地址再传给别人。
友元
友元函数不受访问级别的控制,可以*访问对象的所有成员。
friend Complex &__doapl(Complex *, const Complex &);
friend 赋予其他函数或者类访问类内部protected
或者private
成员的访问权限,打破了类的封装。
看一个类的成员函数:
class Complex {
public:
......
int func(const Complex ¶m) {
return param.re + param.im;
}
private:
int re, im;
......
};
是不是觉得很奇怪,在类当中,私有的属性,只能由类自己的成员函数去获取,而无法被外界所获取。
比如,下面的代码:
Complex complex(0, 0);
cout << complex.re << " " << complex.im << "\n"; // 私有属性对 complex 对象不可见,编译错误
那为什么外界传进来的对象 param 可以去访问 Complex 类的私有属性 re 和 im 呢?
原因在于:友元会打破类的封装性。相同类的各个 objects 互为 friends,在类里面定义一个方法,方法的形参是同类的别的对象,则可以直接获取该对象的私有属性。
操作符重载
1、代码片段一:
complex.h:
Complex &operator+=(const Complex &); // 操作符重载
complex-demo.cpp:
c2 += c1;
如上,编译器怎么看待 c2 += c1;
的 +=
符号,+=
是作用在左边(c2)身上的,要是左边的东西要是对 +=
有定义的话,编译器就找到了。
inline Complex &__doapl(Complex *ths, const Complex &r) {
ths->re += r.re;
ths->im += r.im;
return *ths;
}
inline Complex &Complex::operator+=(const Complex &r) {
return __doapl(this, r);
}
c2 += c1;
结合引用来看待以上三个代码片段:__doapl()、operator+=()、c2 += c1;语句
为什么 __doapl() 方法中,定义的返回类型为 Complex &
,但是却返回 *ths
。因为传递者是不需要知道接收者是以引用形式接收的,这就是使用引用的好处。
看一下 c2 += c1; 语句,首先 c1 是传递者,它要传递到 operator+=(const Complex &r) 中,operator+=() 参数列表中的参数是接收者,由于传递者是不需要知道接收者是以引用形式接收的,因此 c1 可以直接传过去;进入函数体内,接下来会进入 __doapl() 函数
体内;在 __doapl() 函数
中,执行的返回值为 *ths
,但是定义的返回类型为 Complex &,这是因为在__doapl() 函数
这里,*ths
是传递者,将要接收 *ths
的接收者的类型这里定义为 Complex &,由于传递者是不需要知道接收者是以引用形式接收的,因此可以 return *ths
。
2、代码片段二:
inline Complex operator+(const Complex &x, const Complex &y) {
return Complex(real(x) + real(y), imag(x) + imag(y));
}
前面说过,引用传递在「将要被传递的变量是局部的」这种情况下必须使用值传递。假如要用引用传递,我们看一下代码,real(x) + real(y)
运算后,存放结果的对象在哪里,必然需要在函数内部创建一个对象来存储,这个对象在函数生命周期结束时就会立即释放,传递出去的引用将是无效的,因此定义的返回类型为 Complex
,而不是引用 Complex &
。
3、代码片段三:
inline Complex operator+(const Complex &x) { return x; }
inline Complex operator-(const Complex &x) {
return Complex(-real(x), -imag(x));
}
上面这个对「正负号」的重载函数是从标准库中摘抄下来的。也许有人会说,上面不是说传递者不需要接收者是以引用形式接收的吗?x 不是局部变量,优先考虑引用传递,那么正号重载函数为什么不使用引用呢?
答:也可以使用引用,而且速度更快,这里或许说明类即使是标准库,设计的程序性能也不一定是最好的。(有人说是为了正负号重载函数设计的对称美,舍弃了引用)改写如下:
inline const Complex& operator+(const Complex &x) { return x; }
另外,operator - () 只能使用值传递,因为创建的是临时对象。
拷贝构造函数、拷贝赋值函数和析构函数
这三个函数,在欧洲被称为 “Big Three”,三大件,三位一体。
对于不带有指针的类,这「三大件」可以使用编译器默认为我们生成的版本;但是编写带有指针的类时,我们就有必要定义这三个特殊函数。
发现没有,上面举的关于类的例子,都是不带指针的,但是,在 C++ 中,指针是经常被使用的东西,我们知道,当类里面存在指针时,需要自己定义拷贝函数,有没有思考过:如果不自己定义实现而使用库的,会出现什么问题?
我们来看一下这个类:
class String {
public:
......
private:
char *m_data;
};
浅拷贝:假设 String 类有 a 和 b 两个实例对象,a 的数据为 “Hello”,b 的数据为 “World”,即 a 中的 m_data 指针指向的内存块存着字符串 “Hello”,b 的 m_data 指针指向的则为 “World”,但是,“Hello” 和 “World” 都不是属于类里面的成员。当我们使用库提供的拷贝时,不妨假设 a = b; 即把 b 的数据拷贝给 a,此时 a 的 m_data 指针也指向 “World” 了,这样看似也是可以得到想要的值,a 的 m_data 指针和 b 的相同了,都指向了同一块内存空间。但是,此时问题出现了,存着 “Hello” 的那一块内存没有指针指着了,也就是说造成了内存泄漏,这就是一个大问题。另外还有一个问题,一块内存被两个指针指着,这是一件很危险的事,当 b 的指针修改了这块内存的值,a 将会受到影响,不可取。
深拷贝:而使用自己定义的拷贝函数的话,不改变 a 的 m_data 指针值,而是将 b 的数据 “World” 直接拷贝一份放到 a 的 m_data 指针指向的内存空间,解决类内存泄漏的问题。
深拷贝和浅拷贝是要说的重点,现在简要说一下这三个函数,直接 show you code,一目了然:
// 拷贝构造
inline String::String(const String &str) {
m_data = new char[strlen(str.m_data) + 1];
strcpy(m_data, str.m_data);
}
// 析构函数:在类的生命周期即将结束前执行,内存回收,释放掉动态申请的内存
inline String::~String() { delete[] m_data; }
// 拷贝赋值
inline String &String::operator=(const String &str) {
/**
* 检测自我赋值 self assigment
* 很重要,如果没有自我检视并返回,就会进入未知风险状态:
* 先把自己的内存释放掉,然后再照着“自己的内存”申请相同的内存大小,可能引发未知错误
*/
if (this == &str)
return *this;
delete[] m_data; // 先释放自己原来的内存
m_data = new char[strlen(str.m_data) + 1]; // 再重新申请一块大小相等内存
strcpy(m_data, str.m_data);
return *this;
}
栈、堆、内存分配
内存分配
看一段代码:
Complex *pc = new Complex(1, 2);
这一行语句,在编译器看来做了什么?实际上,C++ 编译器执行这条语句,分为了三个步骤:
void *mem = operator new(sizeof(Complex)); // 分配内存
pc = static_cast<Complex*>(mem); // 类型转换
pc->Complex::Complex(1, 2); // 调用构造函数
显然,首先获得一块内存,operator new 其内部调用的 malloc() 去动态申请一块内存,这里 C++ 已经给我们封装好了;然后对指针进行类型转化;最后通过转型后的指针去调用构造函数完成对象的初始化。
new 的示意图:
再来看一段代码:
Complex* pc = new Complex(1, 2);
......
delete pc;
delete pc;
这个语句在编译器看来,是怎么执行的呢?
这里再次 show you the code:
Complex::~Complex(pc); // 析构函数
operator delete(pc); // 释放内存
显然,可以看到,删除 pc 指针,将分为两步进行,第一步:调用析构函数,释放类的属性成员指针指向的内存;第二步,释放该指针动态实例化的类对象。
delete 的示意图:
demo 代码
demo:
#include <cstdlib>
#include <iostream>
class A {
public:
A() {
std::cout << "constructor:" << static_cast<void *>(this) << std::endl;
}
~A() { std::cout << "destory:" << static_cast<void *>(this) << std::endl; }
void *operator new(size_t size) {
std::cout << "new" << std::endl;
return malloc(size);
}
void operator delete(void *ptr) {
std::cout << "delete" << std::endl;
free(ptr);
}
void *operator new[](size_t size) {
std::cout << "new[]" << std::endl;
return malloc(size);
}
void operator delete[](void *ptr) {
std::cout << "delete[]" << std::endl;
free(ptr);
}
};
int main() {
A *a = new A();
delete (a);
A *b = new A[3];
delete[] b;
return 0;
}
输出:
new
constructor:0xffa998
destory:0xffa998
delete
new[]
constructor:0xffa99c
constructor:0xffa99d
constructor:0xffa99e
destory:0xffa99e
destory:0xffa99d
destory:0xffa99c
delete[]
堆、栈
VC 中对象在debug
模式和release
模式下的内存分布如下图所示,变量在内存中所占字节数必须被补齐为16的倍数,红色代表cookie
保存内存块的大小,其最低位的1
和0
分别表示内存是否被回收。
Complex 对象 | String 对象 |
---|---|
数组中的元素是连续的,数组头部 4 个字节记录了数组长度:
Complex 对象 | String 对象 |
---|---|
学习关于对象或对象数组申请这一部分的堆栈,是为了更好地了解编译器的行为,从而帮助我们更好地理解 new 和 delete 和内存相关的操作。
我们都知道,new[]
和delete[]
应该配对使用。为什么要这样做呢?不这样做的后果是什么?学习了内存,根据数组在内存中的状态,自然可以理解为什么这样做了:
delete
操作符仅会调用一次析构函数,而delete[]
操作符依次对每个对象调用析构函数,对于String
这样带有指针的类,若将delete[]
误用为delete
会引起内存泄漏,如下图所示,将造成剩下的两个 String 成员变量指针所指向内存的泄漏。
流程如下:
static 成员
对于类来说,non-static
成员变量每个对象均存在一份,static
成员变量、non-static
和static
成员函数在内存中仅存在一份。其中non-static
成员函数通过指定this
指针获得函数的调用权,而static
函数不需要this
指针即可调用.
static
成员函数可以通过对象调用,也可以通过类名调用:
class Account {
public:
static double m_rate;
static void set_rate(const double& x) { m_rate = x; }
};
double Account::m_rate = 8.0;
int main() {
Account::set_rate(5.0);
Account a;
a.set_rate(7.0);
}
static
成员变量需要在类声明体外进行初始化。
类之间的关系
复合(composition)
复合表示一种has-a
的关系,即类的成员存在一个或多个别的类,STL 中queue
的实现就使用了复合关系。这种结构也被称为「adapter 模式」。
复合关系下构造由内而外,析构由外而内:
委托(aggregation; composition by reference)
pointer to implement(Handle/Body)
委托将类的定义与类的实现分隔开来,也被称为「编译防火墙」。
继承(extension)
继承表示一种is-a
的关系,STL 中_List_node
的实现就使用了继承关系.
继承关系下,构造由内而外,析构由外而内:
父类的析构函数必须是 virtual,否则会出现 undefine behavior
虚函数
成员函数有 3 种:非虚函数、虚函数和纯虚函数:
- 非虚(non-virtual)函數:不希望子类重新定义(override,复写)的函数
- 虚(virtual)函數:子类可以重新定义(override,复写)的函数,且有默认定义
- 纯虚(pure virtual)函數:子类必须重新定义(override,复写)的函数,没有默认定义
虚函数的使用举例说明:「使用虚函数框架」,假设有一个人想要实现一个一般性的文件处理类,这个文件处理过程包括:打开文件、读取文件、关闭文件,现在的思路是,在这个文件处理类里面实现「打开文件」、「关闭文件」这两个操作,但是,「读取文件」操作留给用户定义,因为作者不知道用户要以什么样的方式(格式)去读取这个文件,此时,「读取文件」操作就可以用虚函数来写,不给出具体定义或实现,让用户(子类)去复写,这就是大体的框架。下面是代码流程示例:
如图,框架中父类CDocument
的Serialize()
函数设为虚函数,由框架使用者编写的子类CMyDoc
定义具体的文件处理过程,代码如下:
虚函数是很实用的,因为很多时候,设计者设计的都只是一个框架,里面涉及了哪些功能,设计者都已经给我们想好了,这些都是固定的功能。但是,有另外一些功能是需要私人定制的,根据不同的需求有不同的实现,那么将这些功能函数声明为虚函数,由用户来定义实现,就显得很必要了。
面向对象设计范例
使用委托 + 继承实现 Observer 模式
解决的问题:一个文件,四个窗口,当文件变化时,窗口也会跟着改变。
使用 Observer 模式实现多个窗口订阅同一份内容并保持实时更新:
类结构如下图:
使用委托+继承实现 Composite 模式
使用 Composite 模式实现多态,类结构图如下:
使用委托+继承实现 Prototype 模式
看一张图:
上面的图一开始看的一头雾水,不急,先看比较贴近生活的东西。
相信大多数的人都看过《西游记》,对孙悟空拔毛变出小猴子的故事情节应该都很熟悉。孙悟空可以用猴毛根据自己的形象复制出很多跟自己一模一样的小猴兵出来,其实在设计模式中也有一个类似的模式,我们可以通过一个原型对象来克隆出多个一模一样的对象,这个模式就是「原型模式」。
举个例子:大同小异的工作周报
M 公司一直在使用自行开发的一个 OA 系统进行日常工作办理,但在使用过程中,越来越多的人对工作周报的创建和编写模块产生了抱怨。追其原因,M 公司的 OA 管理员发现,由于某些岗位每周工作存在重复性,工作周报内容都大同小异,如下图所示:
这些周报只有一些小地方存在差异,但是现行系统每周默认创建的周报都是空白报表,因此用户只能通过重新输入或不断地复制与粘贴来填写重复的周报内容,极大地降低了工作效率,浪费宝贵的时间。如何快速创建相同或者相似的工作周报,成为了 M 公司软件开发人员的一个新问题。
M 公司开发人员经过分析,决定按照以下思路对工作周报模块进行重新设计:
(1)除了允许用户创建新周报外,还允许用户将创建好的周报保存为模板(也就是原型)。
(2)用户在再次创建周报时,可以创建全新的周报,还可以选择合适的模板复制生成一个相同的周报,然后对新生成的周报根据实际情况进行修改,产生新的周报。
原型模式概述
从例子可以看出原型模式的定义:
「原型模式(Prototype)」:使用原型实例指定创建对象的种类,并且通过拷贝这些原 型创建新的对象。原型模式是一种对象创建型模式。
原理很简单,将一个原型对象传给那个要发动创建的对象,这个要发动创建的对象通过请求原型对象克隆自己来实现创建过程。
PS:需要注意的是,通过克隆方法所创建的对象时全新的对象。
原型模式的结构如下图所示:
● Prototype(抽象原型类): 它是声明克隆方法的接口,是所有具体原型类的公共父类,可以是抽象类也可以是接口,甚至还可以是具体实现类。
**● ConcretePrototype(具体原型类):**它实现在抽象原型类中声明的克隆方法,在克隆方法中返回自己的一个克隆对象
● Client(客户类): 让一个原型对象克隆自身从而创建一个新的对象,在客户类中只需要直接实例化或通过工厂方法等方式创建一个原型对象,再通过调用该对象的克隆方法即可得到多个相同的对象。由于客户类针对抽象原型类 Prototype 编程,因此用户可以根据需要选择具体原型类,系统具有较好的可扩展性,增加或更换具体原型类都很方便。
再回到这里,再看下面这张示意图就简单了:
代码实现:
Image 类:父类
LandSatImage 类 和 SpotImage 类:继承于 Image 的两个子类
demo:
关于面向对象的内存待更新 …… 生命不息,学习不止 ……