本 Wiki 开启了 HTTPS。但由于同 IP 的 Blog 也开启了 HTTPS,因此本站必须要支持 SNI 的浏览器才能浏览。为了兼容一部分浏览器,本站保留了 HTTP 作为兼容。如果您的浏览器支持 SNI,请尽量通过 HTTPS 访问本站,谢谢!
本页内容是 Boolan C++ 开发工程师培训系列的笔记。
因个人水平有限,我撰写的笔记不免出现纰漏。如果您发现错误,请留言提出,谢谢!
Note:本篇笔记通篇以老师上课所讲的字符串类设计为例说明。
我们知道,带指针的类一般用于一些需要动态内存分配的数据。比如处理变长的字符串,我们不可能定义一个数组去装:要么空间浪费,要么根本就不够大。因此,我们常用的实现方式是使用指针+动态内存申请(比如链表)这样的数据结构来处理前面提到的数据。这样的好处在于灵活,但反过来,这也对类的操作函数提出了更加严格的要求。
为什么会有额外的要求?我们可以先来看一下对上文提到的数据结构可能产生的操作(假设类名为 myString
):
Mystring s0("Hello"); //Default construct
MyString s1(s2); // construct by copy
s1 = s2; // copy assignment
new
和 delete
。
为了实现用字符串直接初始化类对象,我们需要设计一个一般的构造函数来处理这些事情。而需要注意的是用于初始化的字符串可能为空;我们分两种情况来处理:
cstr
为空,我们直接申请一个空间装载字符串结束的标志,表示这个字符串是空字符串;cstr
不为空,新申请足够的空间,然后复制输入的字符串到当前字符串。
inline myString::myString(const char* cstr =0)
{
if (cstr)
{
m_data = new char[strlen(cstr) + 1]; // add one more space to store the string ending sign
strcpy(m_data, cstr);
}
else
{
m_data = new char[1]; //apply a new space for string ending sign
*m_data = '\0';
}
}
有两点需要注意的是:
strlen(cstr) + 1
的操作是为了申请一个空间来装载字符串结束的标志。\0
是 C/C++ 中用于描述 C-Style String 结束的标志(另外一种表达字符串的形式是指针+空间长度)。
我们注意到这里使用了 new
来申请空间给 m_data
,因此必须在析构函数中写上 delete
来释放空间:
inline MyString::~MyString()
{
delete[] m_data;
}
对于 MyString s1(s2)
这样的操作,我们用拷贝构造函数来进行处理。我们在这里实际上进行了两步操作:
s1
,并为其申请足够容纳下 s2
的空间。s2
里的内容复制到 s1
里面去。
实现起来就应该如下了:
inline myString::MyString(const MyString::& str)
{
m_data = new char[ strlen(str.m_data) +1];
strcpy(m_data, str.m_data);
}
拷贝构造函数和前面的一般构造函数写法基本一致;差别在输入对象上。
if (leftUp == nullptr);
拷贝赋值函数(=
操作符重载)是为了实现 s1 = s2
此类的操作。基本的拷贝流程如下:
s1
的内容。s2
的空间大小来申请足够大的空间给 s1
。s2
到 s1
。
值得注意的是,检测自我赋值在这里有非常重要的意义。假设用户输入了自我赋值,即 s1
和 s2
指向的其实是同一个字符串;如果我们跳过第一步检测,直接进行删除 s1
操作,那么经过删除以后,s1
和 s2
都不存在了。
检测方法如下:
if (this == & str)
return *this;
这两者的区别是为什么我们要设计 Big Three 的真正原因。
浅拷贝(Shallow copy),又称为 Field-by-Field Copy,也正是编译器提供的默认拷贝方法,是一种按位拷贝的方法。如果数据结构带引用,那么这种方法只会拷贝引用,而不会拷贝引用指向的内容。
深拷贝(Deep Copy)则是通过新建一个对象,将目标里的内容逐步拷贝到新建对象里的方法( strcpy()
就是拷贝内容的方法)。
回到我们的程序,我们可以看到三个操作开头的(见1)都涉及到了拷贝。假设 A
和 B
都是字符串,我们要将 B
的内容 拷贝给 A
,来看一看如果我们直接使用浅拷贝,会造成什么样的后果:
<html>
<img src=“/_media/programming/cpp/boolan_cpp/leak.svg” width=“700”/>
</html>
看到了吧!如果使用浅拷贝,会导致拷贝和被拷贝的对象的指针同时指向拷贝的对象,而被拷贝的对象内容则失去了指针,从而造成了被拷贝对象内存泄漏。
我们可以考虑下以下两个语句有什么不同:
Complex c1(1,2);
Complex c2 = new Complex(c1, c2);
他们的主要区别也就是栈和堆的区别;栈(Stack)和堆(Heap)是 C++ 中定义的两种使用方法不同的内存空间。
栈是系统为调用函数所准备的空间。当函数被调用的时候,系统会自动在栈顶为被调用函数保留一个内存区域。这些内存区域主要存放局部变量,和一些 Bookkeeping Data(用于内存释放,调试等等,见 2.5)。当函数结束调用时候,系统会自动回收这一部分内存。栈的内存分配策略是 LIFO (Last In First Out),也就是最近被调用的函数(栈顶的内存区)会被最先释放掉。
堆是系统用于动态分配内存所准备的空间。堆中的空间不会自动分配,也不会自动释放,必须要程序员自己指定(new
& delete
)。堆的存放结构类似于链表,申请堆空间的时候必须要指定入口指针。
我们从上面知道堆是要通过程序员手动分配的。在 C++ 中,我们分配的内存的方法就是 new
和 delete
。而我们也知道,申请堆空间是一定要指定入口指针的,因此 new
和 delete
内部也牵涉到了指针的操作
New 和 Delete / New[] 和 Delete[] 必须成对出现!
</WRAP>
我们通过 new
申请一块新的堆空间,在 C++ 中是这么写的:
Class *pc = new Class;
而实际上,这个 new
分成了好几个部分:
class
需要的内存空间,这部份通过 C++ 中的 operator new
函数实现。而在这个过程中, operator new
会返回一个指向新申请空间的指针;由于不清楚申请空间要装什么,我们用一个 void
指针来暂时装载(C++ 中 void
指针可以通过类型转换转换成任何类型的指针)。
void *p = operator new(sizeof(Class));
而通过源码发现, operator new
实际上是在调用 malloc
:
void* p;
while ((p = ::malloc(size)) == 0) {
std::new_handler nh = std::get_new_handler();
}
p
是 void
类型的。但我们使用的时候是使用的类类型的指针,因此我们需要把这个指针的类型转换掉。
Class *pc = static_cast<Class*>(p);
创建对象:空间申请了,也有了入口指针,接下来就是用构造函数创建一个对象啦。因为这里的指针代表着我们的新对象,所以,我们用指针调用构造函数即可:
pc->Class();
这样我们就通过 new
手动的申请了一片堆空间,并在这个堆空间中创建了一个类的对象。
deltete
恰好是跟 new
相反:
调用析构函数:先将先前申请空间中的对象用析构函数摧毁掉:
pc->~Class();
释放内存:跟 new
类似,delete
实际上调用了一个 operator delete
的函数:
operator delete(pc);
而该函数实际上调用了 C 语言中的 free()
函数。
我们用 detete
将指针指向的空间释放掉了,但指针依旧存在。而此时的指针指向了一片被删掉的空间;这个指针是无效的。如果我们再对此指针进行操作,比如:
delete pointer;
delete pointer;
程序就会报错了。
~dtor
{
pointer = nullptr;
}
在 C++ 中删除 nullptr
指针是合法的,因此避免了上述问题。
了解了堆的内存块结构,我们就能明白为什么 new
和 delete
必须配套使用了。
我们通过 new
和 delete
申请或释放的内存块(Block),跟我们在程序里看到的其实不太一样:
<html>
<img src=“/_media/programming/cpp/boolan_cpp/memblk.svg” width=“700”/>
</html>
如图中所示可以看到,我们认为(看到)的对象内容,在内存块里实际只占绿色的部分。内存块里还有其他的内容:
4
位用于判断这个内存块被用到的操作:1
代表是 new
,是系统分配出去的内存块;0
代表是 delete
,表明是系统回收的内存块。pad
。如果内存块的大小不是 16
的倍数,我们将添加这个部分来凑数,直到整个内存块的大小达到 16
的倍数。之所以这么做,是因为红色部分需要用 16 进制的最后 4
位作为标志位。只有内存块是 16
的倍数,最后 4
位才为 0
,我们才能借这几位来作为标志位。
数组在堆内存块中的表现形式和普通内存块大致相似,但多了一个区域用于记录数组的个数(如下图蓝色区域):
<html>
<img src=“/_media/programming/cpp/boolan_cpp/mem_blk_arrayx.svg” width=“700”/>
</html>
这一点对于开头说到的 new
和 delete
必须成对出现的概念非常重要。
必须要提出的是,new
和 delete
分为普通版本和数组版本。数组版本的形式写作:new[]
、delete[]
。而我们所说的成对还包括形式的成对,即 new[]
只能对应 delete[]
。
堆的内存必须通过手动申请创建,手动申请释放。如果只用 new
不用 delete
,那么我们申请的内存空间就会一直存在;然而因为相应的函数逻辑可能已经结束,因此这一段内存空间是不可控的,也就是内存泄漏。
同时,new[]
也必须配合 delete[]
出现。
在数组版本中,每一个数组的元素作为单独的对象出现;而 new[]
和 delete[]
会通过构造 / 析构函数创建 / 销毁对象。因此数组的数量值就是构造 / 析构函数的调用次数。
根据上一节的图,我们不难想到,构造/析构函数调用的次数实际上就存放于蓝色区域内。而普通版本的 new
和 delete
是没有这个调用次数信息的,我们可以认为它们只调用一次构造/析构函数。
试想如果一个 new[]
对应了一个 delete
, new[]
创建了 3
个对象,但 delete
只能删除一个对象(因为没有数组值信息)。尽管 delete
可以通过 cookie 值正确的释放内存,但析构函数的不正确调用也可能会导致另外一种意义上内存泄漏。
有时候我们希望定义一些不同对象需要共用的数据:比如对于不同的银行客户来说,银行的存款利率总是一样的。如果我们把这种数据实例化,那么浪费的空间是很大的。而通过 Static ,我们可以达到各个对象共享单个数据的目的。
Static 成员变量以 static
关键字开头:
static int num;
Static 成员属于类,而不属于任何一个对象。 换句话说,Static 成员是唯一的;因此不能用 this
指针去调用 Static 成员。
type Class::name = value;
Static 成员不能用 this
指针指代。因此普通的成员函数无法对其进行操作(需要 this
parameter)。对此,我们使用静态成员函数来对其专门操作。静态成员函数的声明也是以 static
开头:
static int total;
static int getTotal();
注意:静态成员函数没有 this
指针 parameter, 因此只能访问静态成员变量。
用静态成员函数访问静态成员变量有两种方式:
通过对象调用的写法如下:
obj.static_func(static_val);
通过类名字的写法如下:
Class::static_func(static_val);
cout
是 C++ 中提供的一个 ostream
对象,可以输出大部分已知的 build-in 数据类型。之所以能这样,是因为 ostream
类中重载了大量的 «
操作符。
模板是一种 C++ 提供的处理公共逻辑的方法,其意义在于减少代码的重复。
模板分为两种:类模板和函数模板。
类模板的定义如下:
template<class T>
class MyClass
{
T val = 0;
....
};
函数模板的声明如下:
template<class T>
int func( T& val1, T& val2...)
{
statment......
}
以上定义中的 class
关键字可以用 typename
替换。
类模板和函数模板最主要区别在于函数模板可以自行根据 argument 推断 T
的类型,而类模板必须在对象创建的时候手动指定 T
的类型:
Class<int> obj; //using class template, we must specify the type of object
fun(1, 2); //using function temple, T is deduced as int
前面提到模板的定义可以使用 class
,也可以使用 typename
。那到底区别在哪里?
这两个关键字区别并不是指类模板和函数模板的差别。就功能性上来说,两个关键字实现的功能基本一样。这里有一个主题是讨论这两个关键字的:链接,看了一下需要注意的地方如下:
class
有让人误解的可能,于是又写了一个功能相同的 typename
。class
用于类模板,而 typename
用于其他模板(Effective C++ 3rd)
注:C++ 14 规定嵌套模板的类型关键字必须使用 class
。
template<template<class> typename MyTemplate, class Bar> class Foo { }; // error
template<template<class> class MyTemplate, class Bar> class Foo { }; // good
命名空间(namespace)可以将一系列的程序功能包装到一个指定的空间内。我们访问这个空间中的数据和功能时需要参考命名空间,从而避免了相关数据和功能命名冲突的可能。
命名空间的定义如下:
namespace name
{
statements...
}
使用命名空间的方法有三种:
using namespace std
。全局声明,使用命名空间内所有内容无需再加上命名空间名。using std::cout
,对指定的功能声明,使用命名空间内指定功能无需在加上命名空间名。std::cout
,每次使用功能必须加上命名空间名。不要在头文件里面写 using namespace std,非常容易造成命名空间污染。