======C++面向对象高级编程(上)第二周====== 本页内容是 //Boolan// C++ 开发工程师培训系列的笔记。\\ 因个人水平有限,我撰写的笔记不免出现纰漏。如果您发现错误,请留言提出,谢谢! ---- ====1.带指针的类的设计==== //Note:本篇笔记通篇以老师上课所讲的字符串类设计为例说明。// \\ \\ 我们知道,带指针的类一般用于一些需要动态内存分配的数据。比如处理变长的字符串,我们不可能定义一个数组去装:要么空间浪费,要么根本就不够大。因此,我们常用的实现方式是使用**指针+动态内存申请**(比如链表)这样的数据结构来处理前面提到的数据。这样的好处在于灵活,但反过来,这也对类的操作函数提出了更加严格的要求。 \\ \\ 为什么会有额外的要求?我们可以先来看一下对上文提到的数据结构可能产生的操作(假设类名为 ''myString''): Mystring s0("Hello"); //Default construct MyString s1(s2); // construct by copy s1 = s2; // copy assignment \\ 对于不带指针的类,系统会默认提供一个**按位拷贝**的复制操作来处理类成员之前可能发生的复制操作;这对于线性存储的数据结构来说是没有问题的。但是,如果我们在带指针的数据结构之间使用编译器自带的复制操作(上例),那么就有问题了:我们复制的其实是指向数据的指针,而不是具体的数据;而这个结果会导致严重的问题(见[[cs:programming:cpp:boolan_cpp:oop_a_week2#1.4.浅拷贝和深拷贝|1.4]])。 \\ \\ 因此,我们在设计带指针的类的时候,需要额外考虑三个内容(//Rule of Three or Big Three//):**拷贝构造函数**(//Copy Constructor//),**拷贝赋值操作符**(//Copy Assignment Operator//)和**析构函数**(//Destructor//)。 \\ \\ 特别需要提到的是,在带指针的类中,我们使用的数据结构是动态的申请内存空间的;当这些空间使用完毕之后,我们需要手动的去删除这些空间。编译器默认提供的析构函数无法处理这样的请求,因此我们必须自定义析构函数来确保这些空间的释放,避免造成内存泄漏。相关的 C++ 关键字是:''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); } 拷贝构造函数和前面的一般构造函数写法基本一致;差别在输入对象上。 \\ \\ **2017/04/28补充**: \\ 拷贝操作牵涉到指针的,一定要判断指针指向内容是否为空: if (leftUp == nullptr); \\ ===拷贝赋值函数=== \\ 拷贝赋值函数(''='' 操作符重载)是为了实现 ''s1 = s2'' 此类的操作。基本的拷贝流程如下: - 检测是否是自我赋值(Self Assignment),如果是,不改变当前的内容。 - 清除 ''s1'' 的内容。 - 根据 ''s2'' 的空间大小来申请足够大的空间给 ''s1''。 - 复制 ''s2'' 到 ''s1''。 值得注意的是,**检测自我赋值在这里有非常重要的意义**。假设用户输入了自我赋值,即 ''s1'' 和 ''s2'' 指向的其实是**同一个字符串**;如果我们跳过第一步检测,直接进行删除 ''s1'' 操作,那么经过删除以后,''s1'' 和 ''s2'' 都不存在了。 \\ \\ 检测方法如下: if (this == & str) return *this; ===1.4.浅拷贝和深拷贝=== 这两者的区别是为什么我们要设计 //Big Three// 的真正原因。\\ \\ **浅拷贝**(//Shallow copy//),又称为 //Field-by-Field Copy//,也正是编译器提供的默认拷贝方法,是一种**按位拷贝**的方法。如果数据结构带引用,那么**这种方法只会拷贝引用**,而不会拷贝引用指向的内容。 \\ \\ **深拷贝**(//Deep Copy//)则是通过新建一个对象,将目标里的内容逐步拷贝到新建对象里的方法( ''strcpy()'' 就是拷贝内容的方法)。 \\ \\ \\ {{ cs:programming:cpp:boolan_cpp:deepcopy.jpg?600 |}} \\ \\ 回到我们的程序,我们可以看到三个操作开头的([[cs:programming:cpp:boolan_cpp:oop_a_week2|见1]])都涉及到了拷贝。假设 ''A'' 和 ''B'' 都是字符串,我们要将 ''B'' 的内容 拷贝给 ''A'',来看一看如果我们直接使用浅拷贝,会造成什么样的后果: \\ \\ \\
\\ \\ \\ 看到了吧!如果使用浅拷贝,会导致拷贝和被拷贝的对象的指针同时指向拷贝的对象,而被拷贝的对象内容则失去了指针,从而造成了被拷贝对象内存泄漏。\\ ====内存管理==== 我们可以考虑下以下两个语句有什么不同: Complex c1(1,2); Complex c2 = new Complex(c1, c2); 他们的主要区别也就是栈和堆的区别;**栈**(//Stack//)和**堆**(//Heap//)是 C++ 中定义的两种**使用方法不同**的**内存空间**。 ===栈 Stack=== 栈是系统为调用函数所准备的空间。当函数被调用的时候,系统会自动在**栈顶**为被调用函数保留一个内存区域。这些内存区域主要存放**局部变量**,和一些 //Bookkeeping Data//(用于内存释放,调试等等,见 [[cs:programming:cpp:boolan_cpp:oop_a_week2#2.5.VC 中堆的内存块结构|2.5]])。当函数结束调用时候,系统会自动回收这一部分内存。栈的内存分配策略是 //LIFO// (Last In First Out),也就是最近被调用的函数(**栈顶**的内存区)会被最先释放掉。 ===堆 Heap=== 堆是系统用于动态分配内存所准备的空间。堆中的空间不会自动分配,也不会自动释放,必须要程序员自己指定(''new'' & ''delete'')。堆的存放结构类似于链表,申请堆空间的时候必须要指定入口指针。 ===堆和栈的主要区别=== * 存放的内容不同。栈主要用于存储函数的相关信息(**逻辑**),而堆用于存储相关资料(**数据**)。 * 申请方式不同。栈由系统自动分配,而堆由程序员指派大小和释放。 * 响应方式不同。在栈中,只要栈的空间大于申请空间,系统会为程序持续提供内存。当空间超过上限,产生 //Stack overflow// 的错误。而在堆中,操作系统会提供一个记录空闲地址的链表。当申请指定大小的空间时,系统遍历链表,找出空间大于或者等于申请空间大小的节点,然后将其从链表里删除,同是把空间给用户使用。因此,申请的空间并不一定恰好等于申请的大小。 * 申请大小限制不同。栈是一块连续的内存区域,最大容量是由操作系统事先规划好的。因此,能从栈获得的空间不大。而堆是类似于链表的数据结构,是不连续的。因此,堆获得的空间较灵活,也比栈大。 * 效率不同。栈有系统分配,而且地址连续,因此速度很快。堆由程序员手动分配,速度慢,而且容易产生内存碎片。 ===New & Delete=== 我们从上面知道**堆**是要通过程序员手动分配的。在 C++ 中,我们分配的内存的方法就是 ''new'' 和 ''delete''。而我们也知道,申请堆空间是一定要指定入口指针的,因此 ''new'' 和 ''delete'' 内部也牵涉到了指针的操作\\ \\ New 和 Delete / New[] 和 Delete[] **必须成对出现**! ==New的过程== 我们通过 ''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(p); **创建对象**:空间申请了,也有了入口指针,接下来就是用构造函数创建一个对象啦。因为这里的指针代表着我们的新对象,所以,我们用指针调用构造函数即可: pc->Class(); 这样我们就通过 ''new'' 手动的申请了一片堆空间,并在这个堆空间中创建了一个类的对象。 ==Delete的过程== ''deltete'' 恰好是跟 ''new'' 相反: \\ \\ **调用析构函数**:先将先前申请空间中的对象用析构函数摧毁掉: pc->~Class(); **释放内存**:跟 ''new'' 类似,''delete'' 实际上调用了一个 ''operator delete'' 的函数: operator delete(pc); 而该函数实际上调用了 C 语言中的 ''free()'' 函数。 ==避免野指针== 我们用 ''detete'' 将指针指向的空间释放掉了,但指针依旧存在。而此时的指针指向了一片被删掉的空间;这个指针是无效的。如果我们再对此指针进行操作,比如: delete pointer; delete pointer; 程序就会报错了。 \\ \\ 对于这样的情况,我们应该在析构函数里做出相应的处理: ~dtor { pointer = nullptr; } 在 C++ 中删除 ''nullptr'' 指针是合法的,因此避免了上述问题。 ===VC 中堆的内存块结构=== 了解了堆的内存块结构,我们就能明白为什么 ''new'' 和 ''delete'' 必须配套使用了。 \\ \\ 我们通过 ''new'' 和 ''delete'' 申请或释放的内存块(Block),跟我们在程序里看到的其实不太一样: \\ \\
\\ \\ 如图中所示可以看到,我们认为(看到)的对象内容,在内存块里实际只占绿色的部分。内存块里还有其他的内容: * //红色部分// 称为该内存块的 //Cookie//,用于记录该内存块的大小。该部分的 16 进制值最后 ''4'' 位用于判断这个内存块被用到的操作:''1'' 代表是 ''new'',是系统分配出去的内存块;''0'' 代表是 ''delete'',表明是系统回收的内存块。 * //灰色部分// 只有在调试模式下出现,代表着调试信息。 * //绿色部分// 代表了我们建立的对象存在的区域。 * //黄色部分// 称为 ''pad''。如果内存块的大小不是 ''16'' 的倍数,我们将添加这个部分来凑数,直到整个内存块的大小达到 ''16'' 的倍数。之所以这么做,是因为红色部分需要用 16 进制的最后 ''4'' 位作为标志位。只有内存块是 ''16'' 的倍数,最后 ''4'' 位才为 ''0'',我们才能借这几位来作为标志位。 ==VC 中堆的数组内存块结构== 数组在堆内存块中的表现形式和普通内存块大致相似,但多了一个区域用于记录数组的个数(**如下图蓝色区域**): \\ \\
\\ \\ 这一点对于开头说到的 ''new'' 和 ''delete'' 必须成对出现的概念非常重要。 ===为什么 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'' 关键字开头: static int num; //Static// 成员属于类,而不属于任何一个对象。 换句话说,//Static// 成员是**唯一**的;因此不能用 ''this'' 指针去调用 //Static// 成员。 \\ \\ 注意: //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); ===ostream 对象=== ''cout'' 是 C++ 中提供的一个 ''ostream'' 对象,可以输出大部分已知的 build-in 数据类型。之所以能这样,是因为 ''ostream'' 类中重载了大量的 ''<<'' 操作符。 ===类模板和函数模板=== 模板是一种 C++ 提供的处理公共逻辑的方法,其意义在于减少代码的重复。 \\ \\ 模板分为两种:**类模板**和**函数模板**。 \\ \\ 类模板的定义如下: template class MyClass { T val = 0; .... }; 函数模板的声明如下: template int func( T& val1, T& val2...) { statment...... } 以上定义中的 ''class'' 关键字可以用 ''typename'' 替换。 ==类模板和函数模板的区别== 类模板和函数模板最主要区别在于**函数模板可以自行根据** argument **推断** ''T'' **的类型**,而**类模板必须在对象创建的时候手动指定** ''T'' 的**类型**: Class 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''。那到底区别在哪里?\\ \\ 这两个关键字区别并不是指类模板和函数模板的差别。就功能性上来说,两个关键字实现的功能基本一样。这里有一个主题是讨论这两个关键字的:[[http://stackoverflow.com/questions/213121/use-class-or-typename-for-template-parameters|链接]],看了一下需要注意的地方如下: * //Stroustrup// 觉得 ''class'' 有让人误解的可能,于是又写了一个功能相同的 ''typename''。 * 一般情况下,''class'' 用于类模板,而 ''typename'' 用于其他模板(Effective C++ 3rd) 注:C++ 14 规定嵌套模板的类型关键字必须使用 ''class''。 template typename MyTemplate, class Bar> class Foo { }; // error template class MyTemplate, class Bar> class Foo { }; // good ===命名空间=== 命名空间(namespace)可以将一系列的程序功能包装到一个指定的空间内。我们访问这个空间中的数据和功能时需要参考命名空间,从而避免了相关数据和功能命名冲突的可能。 \\ \\ 命名空间的定义如下: namespace name { statements... } 使用命名空间的方法有三种: * Using directive: 比如 ''using namespace std''。全局声明,使用命名空间内所有内容无需再加上命名空间名。 * Using declaration:比如 ''using std::cout'',对指定的功能声明,使用命名空间内指定功能无需在加上命名空间名。 * 直接使用全名:比如在程序里写 ''std::cout'',每次使用功能必须加上命名空间名。 不要在头文件里面写 using namespace std,非常容易造成命名空间污染。