What & How & Why

差别

这里会显示出您选择的修订版和当前版本之间的差别。

到此差别页面的链接

两侧同时换到之前的修订记录前一修订版
后一修订版
前一修订版
cs:programming:cpp:boolan_cpp:oop_a_week2 [2024/01/14 13:46] – 移除 - 外部编辑 (未知日期) 127.0.0.1cs:programming:cpp:boolan_cpp:oop_a_week2 [2024/01/14 13:47] (当前版本) – ↷ 链接因页面移动而自动修正 codinghare
行 1: 行 1:
 +======C++面向对象高级编程(上)第二周======
 +本页内容是 //Boolan// C++ 开发工程师培训系列的笔记。\\
 +<wrap em>因个人水平有限,我撰写的笔记不免出现纰漏。如果您发现错误,请留言提出,谢谢!</wrap>
 +----
  
 +====1.带指针的类的设计====
 +
 +//<wrap em>Note</wrap>:本篇笔记通篇以老师上课所讲的字符串类设计为例说明。//
 +\\
 +\\
 +我们知道,带指针的类一般用于一些需要动态内存分配的数据。比如处理变长的字符串,我们不可能定义一个数组去装:要么空间浪费,要么根本就不够大。因此,我们常用的实现方式是使用**指针+动态内存申请**(比如链表)这样的数据结构来处理前面提到的数据。这样的好处在于灵活,但反过来,这也对类的操作函数提出了更加严格的要求。
 +\\
 +\\
 +为什么会有额外的要求?我们可以先来看一下对上文提到的数据结构可能产生的操作(假设类名为 ''myString''):
 +<code cpp linenums:1>
 +Mystring s0("Hello"); //Default construct
 +MyString s1(s2); // construct by copy
 +s1 = s2; // copy assignment
 +</code>
 +\\
 +对于不带指针的类,系统会默认提供一个**按位拷贝**的复制操作来处理类成员之前可能发生的复制操作;这对于线性存储的数据结构来说是没有问题的。但是,如果我们在带指针的数据结构之间使用编译器自带的复制操作(上例),那么就有问题了:我们复制的其实是<wrap em>指向数据的指针</wrap>,而不是具体的数据;而这个结果会导致严重的问题(见[[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'' 不为空,新申请足够的空间,然后复制输入的字符串到当前字符串。
 +<code cpp linenums:1>
 +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';
 +    }
 +}
 +</code>
 +有两点需要注意的是:
 +    * ''strlen(cstr) + 1'' 的操作是为了申请一个空间来装载字符串结束的标志。
 +  * ''\0'' 是 C/C++ 中用于描述 //C-Style String// 结束的标志(另外一种表达字符串的形式是指针+空间长度)。
 +\\
 +\\
 +我们注意到这里使用了 ''new'' 来申请空间给 ''m_data'',因此必须在析构函数中写上 ''delete'' 来释放空间:
 +<code cpp linenums:1>
 +inline MyString::~MyString()
 +{
 +    delete[] m_data;
 +}
 +</code>
 +===拷贝构造函数===
 +
 +对于 ''MyString s1(s2)'' 这样的操作,我们用拷贝构造函数来进行处理。我们在这里实际上进行了两步操作:
 +  - 构造一个空的 ''s1'',并为其申请足够容纳下 ''s2'' 的空间。
 +  - 把 ''s2'' 里的内容复制到 ''s1'' 里面去。
 +\\
 +实现起来就应该如下了:
 +<code cpp linenums:1>
 +inline myString::MyString(const MyString::& str) 
 +{
 +    m_data = new char[ strlen(str.m_data) +1];
 +    strcpy(m_data, str.m_data);   
 +}
 +</code>
 +拷贝构造函数和前面的一般构造函数写法基本一致;差别在输入对象上。
 +\\
 +\\
 +**2017/04/28补充**:
 +\\
 +拷贝操作牵涉到指针的,一定要判断指针指向内容是否为空:
 +<code cpp>
 +if (leftUp == nullptr);
 +</code>
 +
 +\\
 +===拷贝赋值函数===
 +\\
 +拷贝赋值函数(''='' 操作符重载)是为了实现 ''s1 = s2'' 此类的操作。基本的拷贝流程如下:
 +  - 检测是否是自我赋值(Self Assignment),如果是,不改变当前的内容。
 +  - 清除 ''s1'' 的内容。
 +  - 根据 ''s2'' 的空间大小来申请足够大的空间给 ''s1''
 +  - 复制 ''s2'' 到 ''s1''
 +值得注意的是,**检测自我赋值在这里有非常重要的意义**。假设用户输入了自我赋值,即 ''s1'' 和 ''s2'' 指向的其实是**同一个字符串**;如果我们跳过第一步检测,直接进行删除 ''s1'' 操作,那么经过删除以后,''s1'' 和 ''s2'' 都不存在了。
 +\\
 +\\
 +检测方法如下:
 +<code cpp linenums:1>
 +if (this == & str)
 +    return *this;
 +</code>
 +
 +===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'',来看一看如果我们直接使用浅拷贝,会造成什么样的后果:
 +\\
 +\\
 +\\
 +<html><div align="center">
 +<img src="/_media/programming/cpp/boolan_cpp/leak.svg"  width="700"/>
 +</div>
 +</html>
 +\\
 +\\
 +\\
 +看到了吧!如果使用浅拷贝,会导致拷贝和被拷贝的对象的指针同时指向拷贝的对象,而被拷贝的对象内容则失去了指针,从而造成了被拷贝对象内存泄漏。\\
 +
 +====内存管理====
 +
 +我们可以考虑下以下两个语句有什么不同:
 +<code cpp linenums:1>
 +Complex c1(1,2);
 +Complex c2 = new Complex(c1, c2);
 +</code>
 +他们的主要区别也就是栈和堆的区别;**栈**(//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'' 内部也牵涉到了指针的操作\\ 
 +\\
 +
 +<WRAP center round important 100%>
 +New 和 Delete / New[] 和 Delete[] **必须成对出现**!
 +</WRAP>
 +
 +</WRAP>
 +
 +==New的过程==
 +
 +我们通过 ''new'' 申请一块新的堆空间,在 C++ 中是这么写的:
 +<code cpp>
 +Class *pc = new Class;
 +</code>
 +而实际上,这个 ''new'' 分成了好几个部分:
 +\\
 +\\
 +**分配内存**:申请 ''class'' 需要的内存空间,这部份通过 C++ 中的 ''operator new'' 函数实现。而在这个过程中, ''operator new'' 会返回一个指向新申请空间的指针;由于不清楚申请空间要装什么,我们用一个 ''void'' 指针来暂时装载(C++ 中 ''void'' 指针可以通过类型转换转换成任何类型的指针)。
 +<code cpp>
 +void *p = operator new(sizeof(Class));
 +</code>
 +而通过源码发现, ''operator new'' 实际上是在调用 ''malloc''
 +<code cpp linenums:1>
 +void* p;
 +while ((p = ::malloc(size)) == 0) {
 +    std::new_handler nh = std::get_new_handler();
 +}
 +</code>
 +\\
 +**转换指针类型**:我们刚才得到的指针 ''p'' 是 ''void'' 类型的。但我们使用的时候是使用的类类型的指针,因此我们需要把这个指针的类型转换掉。
 +<code cpp linenums:1>
 +Class *pc = static_cast<Class*>(p);
 +</code>
 +**创建对象**:空间申请了,也有了入口指针,接下来就是用构造函数创建一个对象啦。因为这里的指针代表着我们的新对象,所以,我们用指针调用构造函数即可:
 +<code cpp>
 +pc->Class();
 +</code>
 +这样我们就通过 ''new'' 手动的申请了一片堆空间,并在这个堆空间中创建了一个类的对象。
 +
 +==Delete的过程==
 +
 +''deltete'' 恰好是跟 ''new'' 相反:
 +\\
 +\\
 +**调用析构函数**:先将先前申请空间中的对象用析构函数摧毁掉:
 +<code cpp>
 +pc->~Class();
 +</code>
 +**释放内存**:跟 ''new'' 类似,''delete'' 实际上调用了一个 ''operator delete'' 的函数:
 +<code cpp>
 +operator delete(pc);
 +</code>
 +而该函数实际上调用了 C 语言中的 ''free()'' 函数。
 +
 +==避免野指针==
 +
 +我们用 ''detete'' 将指针指向的空间释放掉了,但指针依旧存在。而此时的指针指向了一片被删掉的空间;这个指针是无效的。如果我们再对此指针进行操作,比如:
 +<code cpp>
 +delete pointer;
 +delete pointer;
 +</code>
 +程序就会报错了。
 +\\
 +\\
 +对于这样的情况,我们应该在析构函数里做出相应的处理:
 +<code cpp>
 +~dtor
 +{
 +    pointer = nullptr;
 +}
 +</code>
 +在 C++ 中删除 ''nullptr'' 指针是合法的,因此避免了上述问题。
 +
 +===VC 中堆的内存块结构===
 +
 +了解了堆的内存块结构,我们就能明白为什么 ''new'' 和 ''delete'' 必须配套使用了。
 +\\
 +\\
 +我们通过 ''new'' 和 ''delete'' 申请或释放的内存块(Block),跟我们在程序里看到的其实不太一样:
 +\\
 +\\
 +<html><div align="center">
 +<img src="/_media/programming/cpp/boolan_cpp/memblk.svg" width="700"/>
 +</div>
 +</html>
 +\\
 +\\
 +如图中所示可以看到,我们认为(看到)的对象内容,在内存块里实际只占绿色的部分。内存块里还有其他的内容:
 +  * //红色部分// 称为该内存块的 //Cookie//,用于记录该内存块的大小。该部分的 16 进制值最后 ''4'' 位用于判断这个内存块被用到的操作:''1'' 代表是 ''new'',是系统分配出去的内存块;''0'' 代表是 ''delete'',表明是系统回收的内存块。
 +  * //灰色部分// 只有在调试模式下出现,代表着调试信息。
 +  * //绿色部分// 代表了我们建立的对象存在的区域。
 +  * //黄色部分// 称为 ''pad''。如果内存块的大小不是 ''16'' 的倍数,我们将添加这个部分来凑数,直到整个内存块的大小达到 ''16'' 的倍数。之所以这么做,是因为红色部分需要用 16 进制的最后 ''4'' 位作为标志位。只有内存块是 ''16'' 的倍数,最后 ''4'' 位才为 ''0'',我们才能借这几位来作为标志位。
 +==VC 中堆的数组内存块结构==
 +
 +数组在堆内存块中的表现形式和普通内存块大致相似,但多了一个区域用于记录数组的个数(**如下图蓝色区域**):
 +\\
 +\\
 +<html><div align="center">
 +<img src="/_media/programming/cpp/boolan_cpp/mem_blk_arrayx.svg" width="700"/>
 +</div>
 +</html>
 +\\
 +\\
 +这一点对于开头说到的 ''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'' 关键字开头:
 +<code cpp linenums>
 +static int num;
 +</code>
 +//Static// 成员属于类,而不属于任何一个对象。 换句话说,//Static// 成员是**唯一**的;因此不能用 ''this'' 指针去调用 //Static// 成员。
 +\\
 +\\
 +注意: //Static// **成员必须在类外定义**(初始化),格式如下:
 +<code cpp linenums>
 +type Class::name = value;
 +</code>
 +
 +==静态成员函数==
 +
 +//Static// 成员不能用 ''this'' 指针指代。因此普通的成员函数无法对其进行操作(需要 ''this'' parameter)。对此,我们使用静态成员函数来对其专门操作。静态成员函数的声明也是以 ''static'' 开头:
 +<code cpp linenum:1>
 +static int total;
 +static int getTotal();
 +</code>
 +注意:静态成员函数没有 ''this'' 指针 parameter, 因此**只能访问静态成员变量**。
 +==静态成员函数访问的方式==
 +\\
 +用静态成员函数访问静态成员变量有两种方式:
 +  * 通过对象调用
 +  * 通过了类名字调用
 +通过对象调用的写法如下:
 +<code cpp linenums>
 +obj.static_func(static_val);
 +</code>
 +通过类名字的写法如下:
 +<code cpp linenums>
 +Class::static_func(static_val);
 +</code>
 +===ostream 对象===
 +
 +''cout'' 是 C++ 中提供的一个 ''ostream'' 对象,可以输出大部分已知的 build-in 数据类型。之所以能这样,是因为 ''ostream'' 类中重载了大量的 ''<<'' 操作符。
 +
 +===类模板和函数模板===
 +
 +模板是一种 C++ 提供的处理公共逻辑的方法,其意义在于减少代码的重复。
 +\\
 +\\
 +模板分为两种:**类模板**和**函数模板**。
 +\\
 +\\
 +类模板的定义如下:
 +<code cpp>
 +template<class T> 
 +class MyClass 
 +{
 +   T val = 0;
 +    ....
 +};
 +</code>
 +函数模板的声明如下:
 +<code cpp>
 +template<class T>
 +int func( T& val1, T& val2...)
 +{
 +    statment......
 +}
 +</code>
 +以上定义中的 ''class'' 关键字可以用 ''typename'' 替换。
 +
 +==类模板和函数模板的区别==
 +
 +类模板和函数模板最主要区别在于**函数模板可以自行根据** argument **推断** ''T'' **的类型**,而**类模板必须在对象创建的时候手动指定** ''T'' 的**类型**:
 +<code cpp>
 +Class<int> obj; //using class template, we must specify the type of object
 +fun(1, 2); //using function temple, T is deduced as int 
 +</code>
 +==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''
 +<code cpp>
 +template<template<class> typename MyTemplate, class Bar> class Foo { };    //  error
 +template<template<class>    class MyTemplate, class Bar> class Foo { };    //  good
 +</code>
 +
 +===命名空间===
 +
 +命名空间(namespace)可以将一系列的程序功能包装到一个指定的空间内。我们访问这个空间中的数据和功能时需要参考命名空间,从而避免了相关数据和功能命名冲突的可能。
 +\\
 +\\
 +命名空间的定义如下:
 +<code cpp>
 +namespace name
 +{
 +     statements...
 +}
 +</code>
 +使用命名空间的方法有三种:
 +  * Using directive: 比如 ''using namespace std''。全局声明,使用命名空间内所有内容无需再加上命名空间名。
 +  * Using declaration:比如 ''using std::cout'',对指定的功能声明,使用命名空间内指定功能无需在加上命名空间名。
 +  * 直接使用全名:比如在程序里写 ''std::cout'',每次使用功能必须加上命名空间名。
 +<WRAP center round alert 100%>
 +不要在头文件里面写 using namespace std,非常容易造成命名空间污染。
 +</WRAP>