引言
本文涵盖C++对象模型、关键机制、优良编程风格、内存管理,让读者从一无所知到具备大家风范,让读者对于C++有更深入的理解和体会,彻底掌握C++的面向对象与底层运作。
C++ 编程简介
你應具備的基礎
曾經學過某種 procedural language (C 語言最佳)
- 變量 (variables)
- 類型 (types) : int, float, char, struct …
- 作用域 (scope)
- 循環 (loops) : while, for,
- 流程控制 : if-else, switch-case
知道一個程序需要編譯、連結才能被執行
知道如何編譯和連結(如何建立一個可運行程序)
我們的目標
培養正規的、大氣的編程習慣
Object Based (基於對象)
以良好的方式編寫 C++ class
- class without pointer members — Complex
- class with pointer members — String
Object Oriented (面向對象)
學習 Classes 之間的關係
- 繼承 (inheritance)
- 複合 (composition)
- 委託 (delegation)
你將獲得的代碼
complex.h
complex-test.cpp
string.h
string-test.cpp
我们将实现复数和字符串的例子。
C++ 的歷史
- B 語言 (1969)
- C 語言 (1972)
- C++ 語言 (1983)
(new C -> C with Class -> C++) - Java 語言
- C# 語言
可以看到 C++ 是以 C 语言为基础的面向对象语言,是第一个被全世界广泛接受的语言。
C++ 演化
- C++ 98 (1.0)
- C++ 03 (TR1, Technical Report 1)
- C++ 11 (2.0)
- C++ 14
C++ 1983 年就有了,但正规化在 1998 年。上面加粗部分是大版本更新正规化,目前业界大部分程序员是 C++ 11/14(录课时是 2015 年,比较流行的是 C++ 98/11),出现了许多新的工具和特性。
我们学习 C++ 可以分为语言部分和标准库部分,基本所有的编程语言都是这样把这两个分开,使用标准库也是非常重要的事情,本文主要讨论语言部分。
書目誌
在语言部分这两本书可能是全世界卖得最好读者最多的 C++ 百科等级的书籍。
我们学了语言之后很希望得到专家的建议,这本书以调侃的方式告诉你什么该做,什么不该做,做什么样的动作会影响什么样的效率,这是专家给我们的意见。
刚才提到语言,C++ 除了语法本身另外还有标准库。标准库很庞大,我们需要好的书籍帮助学习,上面两本书可以帮助你。
头文件与类的声明
C vs. C++, 關於數據和函數
在 C 语言设计程序的时候,我们会准备一些数据和函数(用于处理数据)。由于语言没有提供足够的关键字,这些数据一定是全局的,对后面有很大影响的。
面向对象语言如 C++ 的想法是把数据和处理数据的函数包在一起,通过创建对象来使用,不会混杂在一起。
对于 Class 分类可以区分为是否带指针,这会影响后面的写法,最有代表性的就是复数和字符串。
Object Based (基於對象) vs. Object Oriented (面向對象)
Object Based: 面對的是單一 class 的設計
Object Oriented : 面對的是多重 classes 的設計, classes 和 classes 之間的關係
再次提醒,当一个类里包含指针时需要非常小心。
C++ programs 代碼基本形式
在我们正式写程序之前来谈谈 C++ 代码基本形式,一般而言会包含头文件、主程序。
Output, C++ vs. C
关于 C 与 C++ 的输出方式。
Header (頭文件) 中的防衛式聲明
|
上面的防卫式声明告诉编译器一进来如果不曾经定义过 _COMPLEX_
,那么就把它定义出来。
|
Header (頭文件) 的佈局
|
class 的聲明 (declaration)
class complex { |
函数可以在类中或者类外定义。
class template (模板) 簡介
前面的 re
、im
都是 double
类型的,那么 int
、float
类型呢?如果我们只修改这个地方却要创建三个类岂不是非常不方便?这就是模板发挥的时候。
我现在的需求是把实部虚部的类型不要写死成 double
,将来用的时候再去定义:
+template<typename T> |
complex<int> c1(2, 1); |
构造函数
inline (內聯)函數
如果你的函数属于 inline
会比较快,而 inline
只是给编译器的建议而已,是不是真正的 inline
还是由编译器决定。
inline double |
补充:内联函数在编译时会把函数的代码副本放置在每个调用该函数的地方,因此对内联函数进行任何修改都需要重新修改,否则将会继续使用旧的函数。
access level (訪問級別)
数据需要封装起来,函数部分则区分为处理内部事务和提供给外部调用两种情况。
下面代码是创建对象,需要调用构造函数。我都想打印它的实部和虚部出来:
complex c1(2, 1); |
constructor (ctor, 構造函數)
你想要创建一个对象构造函数会自动被调用,默认参数、初始化列表都是老生常谈的事情。
ctor (構造函數) 可以有很多個 – overloading (重載)
创建对象你可以有很多想法,所以构造函数可以有很多个,又被称为重载。在大部分例子中都可能看到一个以上的构造函数。
template<typename T> |
同名函数可以有一个以上,既然同样名称那么将来调用的时候调用哪一个呢?其实函数实际名称编译器还是会区分开,编码为一个奇怪的东西,取决于编译器。
参数传递与返回值
constructor (ctor, 構造函數) 被放在 private 區
如果构造函数放在私有区域,那么外界将无法调用构造函数,这个动作是不合理的。
ctors 放在 private 區
最简单的设计模式就是单例模式,它的写法就是刚刚我们说的把构造函数放在 private
里。 static
保证了这里的单一性,外界要这个类需通过 getInstance()
函数取得。
const member functions (常量成員函數)
函数部分的 const
意味着不会改变数据内容,当你在想它的逻辑意义的时候就已经知道要不要加 const
。
參數傳遞:pass by value vs. pass by reference (to const)
参数传递中值传递是整包传过来,如果这个参数很大在 C 语言中可以使用指针,这里的传引用就相当于传指针,但形式上更为优美(其实是语法糖),建议最好所有的参数传递使用引用。
返回值傳遞:return by value vs. return by reference (to const)
引用主要就是用来做参数传递和返回值,选择 C++ 主要就是为了效率,结论是返回值也尽量使用引用。
friend (友元)
inline complex& _doapl(complex* ths, const complex& r) { |
友元就相当于现实生活中的朋友,可以来拿类里封装的数据。虽然我把实部虚部放在 private
就是不想让别人随意拿取,但对于一些函数它是我的朋友,我可以网开一面。
需要注意的是 C++ 强调封装性,而友元无疑是打破这种特性,需要谨慎使用。
相同 class 的各個 objects 互為 friends (友元)
该 func()
接收另一个复数,它直接取得了传进来复数的实部和虚部。相同 class 的各個 objects 互為 friends (友元)。
class body 外的各種定義 (definitions)
- 什麼情況下可以 pass by reference
- 什麼情況下可以 return by reference
inline complex& _doapl(complex* ths, const complex& r) { |
操作符重载与临时对象
operator overloading (操作符重載-1, 成員函數) this
操作符重载是 C++ 很重要的特性。在其他语言里你要对一个东西做操作一般会涉及函数,事实上在 C++ 里面操作符就是一种函数,并且可以自己定义。
return by reference 語法分析
傳遞者無需知道接收者是以 reference 形式接收。
inline complex& _doapl(complex* ths, const complex& r) { |
这里函数头是引用 complex&
,返回的是指针内容 *ths
。这种写法是正确的,虽然你返回的是内容,但接收端如何接收你不必在意,这也是引用的优点。
class body 之外的各種定義 (definitions)
这两个函数没有带命名空间,所以是全局函数:
inline double imag(const complex& x) { |
operator overloading (操作符重載-2, 非成員函數) (無 this)
為了對付 client 的三種可能用法,這兒對應開發三個函數:
inline complex operator+ (const complex& x, const complex& y) { |
temp object(临时对象)typename();
为什么这些函数不返回引用呢?因為,它們返回的必定是個 local object.
class body 之外的各種定義 (definitions)
這個函數絕不可 return by reference, 因為其返回的必定是個 local object。
inline complex operator+ (const complex& x) { |
operator overloading (操作符重載), 非成員函數
inline bool operator== (const complex& x, const complex& y) { |
inline bool operator!= (const complex& x, const complex& y) { |
inline complex conj(const complex& x) { |
对于 <<
操作符你只能选择全局的写法。
这里需要声明 std::
以区别之前的 ostream
,如果按照之前的写法会出错:
#ifndef _COMPLEX_ |
Complex 类实现过程
测试用例
|
具体实现
|
拷贝构造,拷贝赋值与析构函数
String class
int main() { |
需要注意的是这里有两个拷贝动作:
String s3(s1); |
分别是拷贝构造和拷贝赋值。
Big Three, 三個特殊函數
class String { |
一般而言字符串都会这样设计,让字符串里有一个指针 m_data
,在需要内存的时候才去创建另外一个空间创建字符。这是因为字符串的东西有大有小,有时候是空字符串,所以这样的动态设计比较好,而不要在字符串里面放一个数组。
ctor 和 dtor (構造函數 和 析構函數)
字符串长度有两种设计思路,一种是不知道多长,但最后面有一个标识符 \0
;另外一种是后面没有标识符,但前面多一个长度的整数。如果你的对象带有指针,那么大概率需要这样动态分配,那么在析构函数需要将你动态分配的内存释放掉。
inline String::String(const char* cstr) { |
class with pointer members 必須有 copy ctor 和 copy op=
如果成员变量有指针,那么必须包含拷贝构造和拷贝赋值。如果没有自己写,编译器默认做法会造成内存泄漏,上图所示虽然 b
和 a
的内容相同,但原有的 World\0
找不到变成孤儿了,并且两个指针指向同一个区域这个操作本身同样非常危险。
copy ctor (拷貝構造函數)
我们来看看什么叫深拷贝。上面是一个构造函数,拷贝则是因为参数是它本身,好比石头拷贝给石头,人拷贝给人,猪拷贝给猪,所以这个叫拷贝构造函数。拷贝构造应该创造出足够的空间来放置数据。
inline String::String(const String &str) { |
如果没有写这个函数,编译器给的默认版本只会拷贝指针,也称之为浅拷贝,这是我们要避免的。
copy assignment operator (拷貝賦值函數)
拷贝赋值这个概念要把右手的东西赋值或拷贝给左手,假设左右本来都有东西:
- 先把左边清空
- 创建出跟右边一样大的空间
- 再把右边赋值到左边
inline String& String::operator=(const String& str) { |
一定要在 operator= 中檢查是否 self assignment
注意自己赋值给自己的情况,檢測自我賦值 (self assignment)。
堆栈与内存管理
output 函數
|
所謂 stack (棧), 所謂 heap (堆)
Stack,是存在於某作用域 (scope) 的一塊內存空間 **(memory space)**。例如當你調用函數,函數本身即會形成一個 stack 用來放置它所接收的參數,以及返回地址。
在函數本體 (function body) 內聲明的任何變量, 其所使用的內存塊都取自上述 stack。
Heap,或謂 system heap,是指由操作系統提供的 一塊 global 內存空間,程序可動態分配 (dynamic allocated) 從某中獲得若干區塊 **(blocks)**。
class Complex {...}; |
stack objects 的生命期
class Complex { ... }; |
c1 便是所謂 stack object,其生命在作用域 (scope) 結束之際結束。 這種作用域內的 object,又稱為 auto object,因為它會被「自動」清理。
static local objects 的生命期
class Complex {...}; |
c2 便是所謂 static object,其生命在作用域 (scope) 結束之後仍然存在,直到整個程序結束。
global objects 的生命期
class Complex {...}; |
c3 便是所謂 global object,其生命在整個程序結束之後才結束。你也可以把它視為一種 static object,其作用域 是「整個程序」。
heap objects 的生命期
class Complex {...}; |
P 所指的便是 heap object,其生命 在它被 deleted 之際結束。
class Complex {...}; |
以上出現內存洩漏 (memory leak), 因為當作用域結束,p 所指的 heap object 仍然存在,但指針 p 的生命卻結束了,作用域之外再也看不到 p (也就沒機會 delete p)
new:先分配 memory, 再調用 ctor
你的 C++ 语法书籍会查到 new
任何一个东西先得到一片空间,然后调用构造函数。我们把这样的事情结合起来,new
被分解为三个动作:
operator new()
函数调用malloc()
分配内存static_cast<>
把第一步得到的指针进行类型转换- 通过这个指针调用构造函数
Complex()
delete:先調用 dtor, 再釋放 memory
你的语法书还会告诉你,delete
先调用析构函数再释放内存。这个次序跟之前的 new
刚刚相反,这里 delete
被转化为两个动作:
- 首先调用析构函数
~Complex()
operator delete()
函数调用free()
释放内存
動態分配所得的內存塊 (memory block), in VC
如果你分配一个复数,刚刚算过是 8 个字节(2 个 double
),体现在图上就是最左边的亮绿色区块。那么你只得到 8 个字节码?在调试模式下,你会得到上面 4 * 8 = 32 个字节,下面还会多得到一个 4 字节,体现在图上就是最左边的灰色区块。除此之外,你还会得到上下两块红色区块 Cookie 4 * 2 = 8 个字节。由于是在 VC 环境下区块分配必须为 16 的倍数,而 52 不是,所以还要填补青绿色区域 pad 4 * 3 = 12:
$$
Compelex(4 \times 2) + Debug(4 \times 8 + 4) + Cookie(4 \times 2) + Pad(4 \times 3) = 64
$$
对于初学者一般使用 Release 模式,保留 Complex
和 Cookie 再次计算会得到 4 * 2 + 4 * 2 = 16,刚好满足 16 的倍数不需要添加 pad。
上下 Cookie 的作用在于帮助系统回收内存,如果你只给一个指针系统怎么知道该回收多少呢?所以必须记录这个长度。观察 Cookie 的 00000041,4 为 64/16 的倍数,而 1 则代表这块内存已经被分配出去,相当于一个标识符。
对于字符串只内含一个指针,指针大小为 4 字节,在调试模式下加上灰色区块 4 * 8 + 4 = 36 字节,上下 Cookie 为 4 * 2 = 8 个字节。最后得到 48 字节正好是 16 的倍数,所以不需要填补 pad 区域。同理在 Release 模式下 12 不满足 16 的倍数,所以需要填补 4 字节到达 16 进位。
当然你不知道这些事情不会对你的编程有立即的影响,但是知道它我们更能够彻底掌握。
動態分配所得的 array
如果我们分配的是数组呢?假设我要分配三个复数,那么就是 6 个 double
4 * 6 = 24 字节,调试模式下增加上下 4 * 8 + 4 字节,加上上下 Cookie 4 * 2 = 8,最后 VC 的做法会分配一块空间记录数组的大小为 3。
同理可以推导字符串的内存分配。
array new 一定要搭配 array delete
经过刚才的内存分配,我们可以看到 delete[]
的重要性,只有把 []
写出来编译器才知道下面还有数组,而析构函数调用次数的差别会造成内存泄漏。
String 类实现过程
测试用例
int main() { |
具体实现
|
类模板与函数模板
進一步補充:static
对于非静态成员函数,面对不同的参数值 c1
、c2
和 c3
需要使用 this pointer
。而对于静态的数据永远只有一份,比如设计一个银行账户类,有 100 个人来开户,所以你需要创建 100 个户头出来:c1
、c2
……c100
,但有一样东西不应该和账户绑定,就是利率,100 个人用的都是同一份利率,这种情况下就不应该把利率设计为普通的成员数据,而应该设计为只有一份的静态数据。
那什么时候使用静态函数呢?静态函数跟一般成员函数的区别在于静态函数没有 this pointer
,可见它不能像一般成员函数一样去访问、存储和处理对象里的东西,显然只能处理静态数据。
調用 static 函數的方式有二:
- 通過 object 調用
- 通過 class name 調用
進一步補充: 把 ctors 放在 private 區
class A { |
先前提过我们写一个类只希望产生一个对象,这里静态就发挥出作用。上面的代码可以看到 a
是唯一的,而只有通过调用静态函数 getInstance()
才能获取 a
。
这是设计模式中的单例模式,很容易通过静态实现出来。
進一步補充:cout
在前面为了验证我们会用 cout
把东西打印出来,也许你会疑惑为什么 cout
可以接收各式各样的数据,你可以把整数、浮点数、字符串都给它打印出来。
如我们所想,它做了很多操作符重载,所以才能接收如此之多的数据。
進一步補充:class template, 類模板
進一步補充:function template, 函數模板
函数模板不必指出使用类型,编译器会做实参推导得到一个函数版本,引數推導的結果,T
為 stone
,於是調用 stone::operator<
。
進一步補充:namespace
组合与继承
Object Oriented Programming, Object Oriented Design OOP, OOD
- Inheritance (繼承)
- Composition (複合)
- Delegation (委託)
对于复数或字符串一般不会和其他类发生关系,但一些比较复杂的情况你就需要让类和类之间产生关系,这就叫面向对象编程思想。
Composition (複合), 表示 has-a
上面是标准库的队列实现,默认 Sequence
为 deque<T>
类型。
我里面有另外一种东西,称之为复合,表示的是“has a”的关系。很显然这里的 deque
功能非常强大,queue
在其基础上调用操作函数即可。
现在我们从内存的角度来看一看。Itr
有 4 个指针 4 * 4 = 16 个字节,而 deque
则有 2 个 Itr
16 * 2 = 32 字节,加上多的 1 个指针 1 个非负整型 4 + 4 = 8,最后得到 40 字节,而 queue
本身只有一个 deque
,所以大小也是 40。
Composition (複合) 關係下的構造和析構
構造由內而外
Container 的構造函數首先調用 Component 的 default 構造函數,然後才執行自己。
Container::Container(...): Component() {...}; |
析構由外而內
Container 的析構函數首先執行自己,然後才調用 Component 的析構函數。
Container::~Container(...){ ~Component() }; |
Delegation (委託). Composition by reference.
可以看到 String
拥有一个 StringRep
的指针,我们称之为 Delegation (委託)。这一种 pimpl 写法非常有名,字符串的设计不在左边写出来,左边只是对外的接口,设计的实现都在右边写出来,当左边需要动作的时候都调用右边的类的函数来服务。可以看到 Pimpl 拥有如下优点:
- 减少依赖项(降低耦合性):其一减少原类不必要的头文件的依赖,加速编译;其二对Impl类进行修改,无需重新编译原类
- 接口和实现的分离(隐藏了类的实现):私有成员完全可以隐藏在共有接口之外,给用户一个间接明了的使用接口,尤其适合闭源API设计
- 可使用惰性分配技术:类的某部分实现可以写成按需分配或者实际使用时再分配,从而节省资源
Pimpl也拥有一些缺点:
- 每个类需要占用小小额外的指针内存
- 每个类每次访问具体实现时都要多一个间接指针操作的开销,并且再使用、阅读和调试上都可能有所不便。
可以说,在性能/内存要求不敏感(非极端底层)的领域,Pimpl 技术可以有相当不错的发挥和作用。
Inheritance (繼承), 表示 is-a
使用 public
Inheritance (繼承) 代表“is a”的关系。
Inheritance (繼承) 關係下的構造和析構
構造由內而外
Derived 的構造函數首先調用 Base 的 default 構造函數, 然後才執行自己。
Derived::Derived(...): Base() {...}; |
析構由外而內
Derived 的析構函數首先執行自己,然後才調用 Base 的析構函數。
Derived::~Derived(...){ ... ~Base() }; |
虚函数与多态
Inheritance (繼承) with virtual functions (虛函數)
non-virtual 函數:你不希望 derived class 重新定義 (override, 覆寫**)** 它。
virtual 函數:你希望 derived class 重新定義 (override, 覆寫**)** 它,且你對它已有默認定義。
pure virtual 函數:你希望 derived class 一定要重新定義 (override 覆寫**)** 它,你對它沒有默認定義。
Inheritance (繼承) with virtual
这里我使用 PPT 在菜单栏选择打开文件,弹出的窗口有文件名搜索栏。如果我输入一个文件名,它应该检查文件名是否正确,然后到硬盘里找这个文件在不在,最后把这个文件打开。
在这个流程中除了最后打开文件由于格式不同可能读取存在问题,其他步骤都可以提前写好。
在这个例子中读取文件 Serialize()
是没办法提前写好的,所以我们需要将其设置为纯虚函数或者包含定义的虚函数。
Inheritance+Composition 關係下的構造和析構
構造由內而外
Derived 的構造函數首先調用 Base 的 default 構造函數, 然後調用 Component 的 default 構造函數, 然後才執行自己。
Derived::Derived(...): Base(),Component() { ... }; |
析構由外而內
Derived 的析構函數首先執行自己, 然後調用 Component 的 析構函數,然後調用 Base 的析構函數。
Derived::~Derived(...){ ... ~Component(), ~Base() }; |
委托相关设计
Delegation (委託) + Inheritance (繼承)
现在我们有了复合、继承和委托三板斧,如果我的任务是做一个文件系统,我们该如何设计呢?现在打开 Windows 窗口系统,都是大窗口里有小窗口,而文件目录可以放文件,还可以与其他目录结合在一起再放到另外一个目录里,现在我们面对的就是这样一种奇特的情况。
首先我们应该有一个代表文件的类 Primitive
,另外我也需要准备一个 Composite
组合类。作为 Compostie
应该是一个容器,可以容纳很多个 Primitive
,但刚刚我们分析过它也应该能够容纳 Composite
本身,那该如何是好呢?
我们的策略是为左边和右边写一个父类:
Primitive
is aComponent
Composite
is aComponent
这样右边的容器不必写死为 Primitive
或 Composite
,放入父类指针 Component*
。同样,添加功能也需要添加两个类别,所以也放入父类指针 Component*
。在设计模式中这种设计方法称为组合模式。
假如我需要一个树状继承体系,创建未来才需要的子类该怎么办?在图中可以看到 Image
是抽象层之上的,下面的 LandImage
和 SpotImage
都是派生下来的子类,这时名称才可能出现,而 Image
可能是我三年前写的。现在的问题是我不知道未来的类有什么,我如何创建它呢?
有些聪明的人想出一个办法,有没有办法让下面派生的子类都创建一个自己作为原型传给父类。只要派生子类创建出来的东西能够被父类看到,就可以当作蓝本不断拷贝。以 LandSatImage
为例,私有的构造函数会调用 Image
父类 addPrototype(this)
使其感知,Image
得到的指针再放到自己的容器数组 prototypes
,依此类推。而父类就可以通过之前传递的原型调用子类的 clone()
函数。
有人会说把 clone()
函数设为静态,不需要对象就能够调用它。静态函数的调用一定需要类名称,而未来的类名我们并不知道,所以我们必须舍弃这个想法。这个解法十分精巧,让人拍案叫绝,由《Design Patterns Explained Simply》的作者提出的。
Prototype
|
父类的 clone()
为纯虚函数,它不知道怎么克隆,但它要求子类一定要写出来。
class LandSatImage : public Image { |
// Simulated stream of creation requests |
完整的源码:
|