What & How & Why

C++面向对象高级编程(上)第二周

本页内容是 Boolan C++ 开发工程师培训系列的笔记。
因个人水平有限,我撰写的笔记不免出现纰漏。如果您发现错误,请留言提出,谢谢!


1.带指针的类的设计

Note:本篇笔记通篇以老师上课所讲的字符串类设计为例说明。

我们知道,带指针的类一般用于一些需要动态内存分配的数据。比如处理变长的字符串,我们不可能定义一个数组去装:要么空间浪费,要么根本就不够大。因此,我们常用的实现方式是使用指针+动态内存申请(比如链表)这样的数据结构来处理前面提到的数据。这样的好处在于灵活,但反过来,这也对类的操作函数提出了更加严格的要求。

为什么会有额外的要求?我们可以先来看一下对上文提到的数据结构可能产生的操作(假设类名为 myString):

Mystring s0("Hello"); //Default construct
MyString s1(s2); // construct by copy
s1 = s2; // copy assignment

对于不带指针的类,系统会默认提供一个按位拷贝的复制操作来处理类成员之前可能发生的复制操作;这对于线性存储的数据结构来说是没有问题的。但是,如果我们在带指针的数据结构之间使用编译器自带的复制操作(上例),那么就有问题了:我们复制的其实是指向数据的指针,而不是具体的数据;而这个结果会导致严重的问题(见1.4)。

因此,我们在设计带指针的类的时候,需要额外考虑三个内容(Rule of Three or Big Three):拷贝构造函数Copy Constructor),拷贝赋值操作符Copy Assignment Operator)和析构函数Destructor)。

特别需要提到的是,在带指针的类中,我们使用的数据结构是动态的申请内存空间的;当这些空间使用完毕之后,我们需要手动的去删除这些空间。编译器默认提供的析构函数无法处理这样的请求,因此我们必须自定义析构函数来确保这些空间的释放,避免造成内存泄漏。相关的 C++ 关键字是:newdelete

一般构造函数

为了实现用字符串直接初始化类对象,我们需要设计一个一般的构造函数来处理这些事情。而需要注意的是用于初始化的字符串可能为空;我们分两种情况来处理:

  • 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) 这样的操作,我们用拷贝构造函数来进行处理。我们在这里实际上进行了两步操作:

  1. 构造一个空的 s1,并为其申请足够容纳下 s2 的空间。
  2. 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 此类的操作。基本的拷贝流程如下:

  1. 检测是否是自我赋值(Self Assignment),如果是,不改变当前的内容。
  2. 清除 s1 的内容。
  3. 根据 s2 的空间大小来申请足够大的空间给 s1
  4. 复制 s2s1

值得注意的是,检测自我赋值在这里有非常重要的意义。假设用户输入了自我赋值,即 s1s2 指向的其实是同一个字符串;如果我们跳过第一步检测,直接进行删除 s1 操作,那么经过删除以后,s1s2 都不存在了。

检测方法如下:

if (this == & str)
    return *this;

1.4.浅拷贝和深拷贝

这两者的区别是为什么我们要设计 Big Three 的真正原因。

浅拷贝Shallow copy),又称为 Field-by-Field Copy,也正是编译器提供的默认拷贝方法,是一种按位拷贝的方法。如果数据结构带引用,那么这种方法只会拷贝引用,而不会拷贝引用指向的内容。

深拷贝Deep Copy)则是通过新建一个对象,将目标里的内容逐步拷贝到新建对象里的方法( strcpy() 就是拷贝内容的方法)。




回到我们的程序,我们可以看到三个操作开头的(见1)都涉及到了拷贝。假设 AB 都是字符串,我们要将 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++ 中定义的两种使用方法不同内存空间

栈 Stack

栈是系统为调用函数所准备的空间。当函数被调用的时候,系统会自动在栈顶为被调用函数保留一个内存区域。这些内存区域主要存放局部变量,和一些 Bookkeeping Data(用于内存释放,调试等等,见 2.5)。当函数结束调用时候,系统会自动回收这一部分内存。栈的内存分配策略是 LIFO (Last In First Out),也就是最近被调用的函数(栈顶的内存区)会被最先释放掉。

堆 Heap

堆是系统用于动态分配内存所准备的空间。堆中的空间不会自动分配,也不会自动释放,必须要程序员自己指定(new & delete)。堆的存放结构类似于链表,申请堆空间的时候必须要指定入口指针。

堆和栈的主要区别

  • 存放的内容不同。栈主要用于存储函数的相关信息(逻辑),而堆用于存储相关资料(数据)。
  • 申请方式不同。栈由系统自动分配,而堆由程序员指派大小和释放。
  • 响应方式不同。在栈中,只要栈的空间大于申请空间,系统会为程序持续提供内存。当空间超过上限,产生 Stack overflow 的错误。而在堆中,操作系统会提供一个记录空闲地址的链表。当申请指定大小的空间时,系统遍历链表,找出空间大于或者等于申请空间大小的节点,然后将其从链表里删除,同是把空间给用户使用。因此,申请的空间并不一定恰好等于申请的大小。
  • 申请大小限制不同。栈是一块连续的内存区域,最大容量是由操作系统事先规划好的。因此,能从栈获得的空间不大。而堆是类似于链表的数据结构,是不连续的。因此,堆获得的空间较灵活,也比栈大。
  • 效率不同。栈有系统分配,而且地址连续,因此速度很快。堆由程序员手动分配,速度慢,而且容易产生内存碎片。

New & Delete

我们从上面知道是要通过程序员手动分配的。在 C++ 中,我们分配的内存的方法就是 newdelete。而我们也知道,申请堆空间是一定要指定入口指针的,因此 newdelete 内部也牵涉到了指针的操作

New 和 Delete / New[] 和 Delete[] 必须成对出现

</WRAP>

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();
}

转换指针类型:我们刚才得到的指针 pvoid 类型的。但我们使用的时候是使用的类类型的指针,因此我们需要把这个指针的类型转换掉。
Class *pc = static_cast<Class*>(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 中堆的内存块结构

了解了堆的内存块结构,我们就能明白为什么 newdelete 必须配套使用了。

我们通过 newdelete 申请或释放的内存块(Block),跟我们在程序里看到的其实不太一样:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/memblk.svg” width=“700”/>

</html>

如图中所示可以看到,我们认为(看到)的对象内容,在内存块里实际只占绿色的部分。内存块里还有其他的内容:

  • 红色部分 称为该内存块的 Cookie,用于记录该内存块的大小。该部分的 16 进制值最后 4 位用于判断这个内存块被用到的操作:1 代表是 new,是系统分配出去的内存块;0 代表是 delete,表明是系统回收的内存块。
  • 灰色部分 只有在调试模式下出现,代表着调试信息。
  • 绿色部分 代表了我们建立的对象存在的区域。
  • 黄色部分 称为 pad。如果内存块的大小不是 16 的倍数,我们将添加这个部分来凑数,直到整个内存块的大小达到 16 的倍数。之所以这么做,是因为红色部分需要用 16 进制的最后 4 位作为标志位。只有内存块是 16 的倍数,最后 4 位才为 0,我们才能借这几位来作为标志位。
VC 中堆的数组内存块结构

数组在堆内存块中的表现形式和普通内存块大致相似,但多了一个区域用于记录数组的个数(如下图蓝色区域):

<html>

<img src=“/_media/programming/cpp/boolan_cpp/mem_blk_arrayx.svg” width=“700”/>

</html>

这一点对于开头说到的 newdelete 必须成对出现的概念非常重要。

为什么 New 和 Delete 必须成对出现

必须要提出的是,newdelete 分为普通版本数组版本。数组版本的形式写作:new[]delete[]。而我们所说的成对还包括形式的成对 new[] 只能对应 delete[]

堆的内存必须通过手动申请创建,手动申请释放。如果只用 new 不用 delete,那么我们申请的内存空间就会一直存在;然而因为相应的函数逻辑可能已经结束,因此这一段内存空间是不可控的,也就是内存泄漏

同时,new[] 也必须配合 delete[] 出现

在数组版本中,每一个数组的元素作为单独的对象出现;而 new[]delete[] 会通过构造 / 析构函数创建 / 销毁对象。因此数组的数量值就是构造 / 析构函数的调用次数

根据上一节的图,我们不难想到,构造/析构函数调用的次数实际上就存放于蓝色区域内。而普通版本的 newdelete 是没有这个调用次数信息的,我们可以认为它们只调用一次构造/析构函数。 试想如果一个 new[] 对应了一个 deletenew[] 创建了 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 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。那到底区别在哪里?

这两个关键字区别并不是指类模板和函数模板的差别。就功能性上来说,两个关键字实现的功能基本一样。这里有一个主题是讨论这两个关键字的:链接,看了一下需要注意的地方如下:

  • Stroustrup 觉得 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 directive: 比如 using namespace std。全局声明,使用命名空间内所有内容无需再加上命名空间名。
  • Using declaration:比如 using std::cout,对指定的功能声明,使用命名空间内指定功能无需在加上命名空间名。
  • 直接使用全名:比如在程序里写 std::cout,每次使用功能必须加上命名空间名。

不要在头文件里面写 using namespace std,非常容易造成命名空间污染。