侯捷 C++面向对象程序设计的下半部分笔记
Object-Oriented-Programming
一、类型转换函数(Conversion function)
//Fraction.h
class Fraction{
public:
Fraction(int num,int den = 1)
: m_numerator(num), m_denominator(den) {}
operator double() const {
return (double)(m_numerator / m_denominator);
}
private:
int m_numerator; //分子
int m_denominator; //分母
};
//main.cpp
int main(){
Fraction f(3,5);
double d = 4 + f; //corrcet
return 0;
}
- opearator double() const { },类型转换 function 属于单目操作符,cpp 类型转换重载不需要声明返回类型
- 编译器在编译到 double d = 4 + f " 时,会先考虑是否有重载+,先看 4 有没有重载+,显然没有,再看全局函数有没有重载+,又没有。这时,编译器会想其他办法,比如看有没有办法将 Fraction 对象转成 double,因为重载了 double 类型转换操作符,所以编译通过
- 只要你认为合理,可以写多个类型转换函数
1. Non-explicit-one-argument ctor
//fraction.h
class Fraction
{
public:
Fraction(int num,int den = 1):m_numerator(num),m_denominator(den){}
Fraction operator+(const Fraction& f){
return Fraction(......);
}
private:
int m_numerator;
int m_denominator;
}
//main.cpp
Fraction f(3,5);
fraction d2 = f + 4;
- explicit 是 cpp 的关键字,non-explicit-one-argument 就是没有 explicit 关键字的,一个实参的 ctor(第二个参数是有默认值的,所以 ctor 可以只有一个参数)
- 编译器在执行“fraction d2 = f + 4”时,先看 Fraction 有没有重载 + ,重载了,所以调用重载的加法,但是重载的加法的第二个参数不是 int 型,所以编译器想办法,看能不能把“4”转化成“Fraction”,因为“Fraction”有单个参数的 ctor,所以可以,编译通过
2. Convertion function vs non-explicit-one-argument ctor
- ambiguous,歧义
- 按绿色,4 转为 Fraction object,可以执行 Fraction 重载后的加法,编译通过
- 按黄色,Fraction object 转为 double,double 加 4,还是 double,double 又可以转化为 Fraction(通过 non-explicit-one-argument ctor),编译通过
因此产生了歧义,当两条路都能走通的时候,编译器不知道走哪一条,所以会报错;这里需要注意的是,按黄色的情况中,Fraction d2 = 4.6 是可以这样写的,也是初始化的一种写法,相当于“Fraction d2(4.6)”;
class 的 ctor 在默认情况下,是 implicit 的,意为隐式的,编译器会添加将 one arugment 转化为 Fraction 的转化方法,所以绿色部分可以行的通,因为编译器可以通过 ctor 将 int 转化为 Fraction;
3. Explicit one-argument ctor
- ctor 默认是 implicit 的,添加关键字 explicit 变为显示的,这样一来,编译器无法再将 double 转为 Fraction,绿、黄两条路都行不通,直接报错
- explicit 就是告诉编译器,不可以再把 ctor 函数用于类型转换,我又没有明白说要这么做
- explicit 关键字只有 ctor 这里才能用到,不过在模板的很小的一个地方也用的上,但是很细微,很少有人注意,就不考虑了
4. Conversion function 在 STL 中的应用
- 代理模式
二、智能指针(Pointer-like classes)
1. Pointer like classes
一个 C++ Class 产生的 Object 可能会像两种东西:
- Class 产生的对象像一个指针,pointer-like classes,智能指针
- Class 产生的对象像一个函数
Pointer-like classes 就是将原有的指针封装起来的 class,在不改变原有指针功能的基础上提供更多的功能,比如自动释放内存之类的,所以智能指针需要重载指针的操作符,如“*”,“->”:
- 上图中“*”的重载很好理解,重点是“->”的重载,“->”有一个特殊的行为,作用下去的结果会继续作用下去,这两个操作符重载的写法是固定的。
2. Iterator
除了基本的智能指针外,还有另一种智能指针,迭代器:
- 库(Lib)的使用往往会用到容器,而容器本身一定带着迭代器(iterator)。
- 迭代器也是指针的封装,指向容器的一个元素,但是比一般的智能指针多重载了许多操作符,比如++和--;
- 例子中的 iterator 是 T 的迭代器,从外界(使用者)看来就是指向 T 的指针,所以 iterator 的“*”、“->”返回的是 T 本身和 T 的地址。
相当于 T(容器元素)被进行了两次封装,先被封装在一个双向链表的数据结构里,然后再将这个数据结构的指针封装成 iterator,进行第一次封装是为了更好地访问查找 T,进行第二次封装是为了智能化指针
三、仿函数(Function like classes)
1. Operator “()”Overload
- “()”,Function Call Operator,函数调用操作符
- 所以任何一个 Objcet 如果能接受“()”,则被称为“像函数”或仿函数
- 用例,“select1st()(const Pair& x);”,“select1st()”是创建临时对象
2. STL 中仿函数的继承
- 标准库里的仿函数都会继承一些 class,比如 unary_function 和 binary_function,与操作数的个数有关,这里不讨论
- 上面两个 class 的大小,虽然没有数据,但实现上因为一些限制,得到的大小是 1
- STL 中有很多的仿函数,都有重载“()”,都继承了一些奇怪的父类
四、命名空间(Namespace)
1. Namespace 经验谈
- 将一些代码区分开,防止独立作业(两个办公室互不沟通写代码)时,名字冲突
- 在写一些全局的函数时,可以用 namespace 包起来,然后通过 namespace 调用,比如在很多测试函数时,你可以都叫 test,用不同的 namespace 包起来(namespace 的名称可以命名成要测试的东西的名称),这样就不用想很多的函数名
Template Programming
五、模板(Class Template)
1. Class Template,类模板
template<typename T>
class complex
{
public:
complex(T r = 0,T i = 0): re(r),im(i){}
complex& operator += (const complex&);
T real () const {return re;}
T imag () const {return im;}
private:
T re,im;
friend complex& __doapl (complex*,const complex&);
};
{
complex<double> c1(2.5,1.5);
complex<int> c2(2,6);
}
- 类模板在使用时需要指明
<typename>
2. Function Template,函数模板
template<class T>
inline const T& min(const T& a,const T& b)
{
return b<a?b:a;
}
//usage:
{
stone r1(2,3),r2(3,3),r3;
r3 = min(r1,r2); //stone 类要重载 <
}
- 函数模板在使用时编译器会自动推导参数
3. Member Template,成员模板
template <class T1,class T2>
struct pair{
typedef T1 first_type;
typedef T2 second_type;
T1 first;
T2 second;
pair():first(T1()),secoond(T2()){}
pair(const T1&a , const T2& b):first(a),second(b){}
template <class U1,class U2>
pair(const pair<U1,U2>& p): first(p.first),second(p.seconnd){}
};
A:在生活中鲫鱼是属于鱼类的,麻雀是属于鸟类的,反过来是不能说鱼类属于鲫鱼,鸟类属于麻雀的,所以是不可以的。这段代码实现的也是这样的效果。
类模板允许了 first 和 second 可以是任意类型 T1,T2,成员模板又允许了 pair 的构造函数可以传递任意类型的 pair<U1,U2>
,但是必须满足 first(p.first),second(p.second) 可以成立,所以反过来时,父类是无法赋值给子类的,所以不可以。
Q:为什么不把 U1,U2 写成 T1,T2 呢? A:因为这样写的话,就没法让子类做构造函数的参数了
- 使用成员模板可以是成员函数的参数更加灵活,比如能接受继承关系下的子类
- 父类指针指向子类,这种写法叫做up-cast,上转型,只使用父类的方法。
- cpp 中的智能指针为了实现 up-cast,就需要在 ctor 使用成员模板来接纳子类指针
5. Specialization,模板特化
//模板泛化
template <class Key>
struct hash { };
//模板特化。特化就是绑定的意思,因为绑定了,所以 template 里面什么都没有
template<>
sturct hash<char> {
size_t operator()(char x) const { return x; };
template<>
sturct hash<int> {
size_t operator()(int x) const { return x; }
};
template<>
sturct hash<long> {
size_t operator()(long x) const { return x;}
};
- 特化与泛化相对,泛化是希望模板能完成所有的同类的事,而特化是希望特定的模板完成特定的事
- 泛化为用到 template 的所有代码,而特化只用到特化的那一段
6. Partial Specialization,模板偏特化
- 个数的上的偏,template 的前几个参数是指定的
- 范围上的偏,template 的 T 原本是任意类型,现在被限制在了指针类型 T*
特化(Specialization) 泛化(Full Specialization) 偏特化(Partial Specialization)
7. template template parameter,模板模板参数
- Container 只是一个代名词,相当于 abcd,它用模板的第一个参数做参数
- 当想要使用模板做为类模板的参数时,就需要如图中这样定义,比如第二个参数是没指定类型的模板容器
Q:第二个参数要指定成模板的话,那为什么
XCls<string,list>
报错了呢? A:按XCls<string,list>
的写法,Container<T>
会变成list<T>
,这应该是行的通的,为什么行不通了呢? 因为 cpp 的容器模板有第二模板参数,甚至第三模板参数,我们平常用的时候不需要写是因为他们有默认值,但是这里的语法是过不了的。 正确的写法是加上上图第二个框那里的内容。这是 Cpp2.0 里的新特性,先不做解释。
- shared_ptr 和 auto_ptr 之所以能过编译是因为他们都只有一个模板参数,而且模板参数可以指定为 string
- 第一个用例,因为第二个参数有默认值,所以可以只指定一个模板参数
- 第二个用例,第二个参数也可以这样写,但第二个参数已经绑定写死了,已经不再是模板了,所以与上面的模板模板参数不同,上面是模板做参数,下面是绑定的模板做参数
六、C++标准库以及 C++ 11
1. C++ 标准库
- 作为初学者一定要多使用、熟用标准库
- 标准库的两大部分:容器和算法
- algorithm + data structures = programs
- programs + data = software
- 对库的学习,看代码的效果不太好,需要自己去写调用测试的小例子
2. C++ 11 的学习建议
优先学习:
- variadic template
- auto
- range-base for loop
Q:了解编译器对 C++11 的支持,不同的平台可能需要不同的设置,如何确定自己是否设置成功了呢? A:如上图,cplusplus 在 C++98 和 C++11 中的定义值不同,故可通过输出cplusplus 的值来判断是否支持 C++11
3. Variable Templates(since C++11),数量不定的模版参数
- variadic 是一个生造的词,var 这个词根意为可变化的
- 作用:允许你写任意个数的模版参数,通过"..."语法
- 写法解释:
- 这个模版函数放进去的模版参数分为两个部分,一个和一包 (pack),一个 T 和一包 Types,
typename T
代表的是模版参数可以是任意类型,而typename... Types
代表的是模版参数可以是任意类型且任意数量 - 然后在函数模版中使用
Types...
来声明函数参数(注意这里是函数参数,而非模版参数),被声明为Types...
类型的函数参数 args 就会代表接收任意类型的任意数量的参数 - 之后在函数的
scope
中使用的args...
就会代表传进来的任意类型任意数量的参数包(pack) - Usage 中展示的是一种递归调用的写法,其中编译器会自动匹配"一个和一个包"的界限在哪,或者说,函数接收
args...
就相当于接受了一大块参数,然后根据print(T,Types)
来匹配参数 - 最后当参数 args 只剩一个时,args 被分配给前面的 T,下一层的 args 接受到的参数是 0 个,所以要写一个
void print(){}
。
- 这个模版函数放进去的模版参数分为两个部分,一个和一包 (pack),一个 T 和一包 Types,
- 使用:使用时,因为是函数模版,所以无需指定类型,根据函数的内容,bitset 类型应该重载"<<"
4. Auto(since C++11),自动类型推断
- auto 是一种语法糖,请求编译器自动推导变量的类型
- 全部都用 auto 是一种非常极端的做法,一般都是太长不想写,或者一些特殊情况下不知道怎么写类型时才使用 auto
5. Ranged-Base For (since C++11)
- 用于遍历容器(collection),配合 auto 使用简直不要太爽
- auto 是 pass by value,auto 也可以加上引用变成 auto&,pass by reference
6.Reference 再解
&
出现的位置不同的意思也不同,&x
表示取 x 的地址,属于运算符,int& r = x;
表示 r is a reference to int x(从右往左念),从此r
就代表x
,且它无法代表其他的东西- 因为
r
代表x
,所以sizeof(r)
就相当于sizeof(x)
,&x
就相当于&r
,计算r
的地址和大小的结果和x
完全一样,但这是一种编译器故意做出来的假象,对用户将引用以一种别名的方式展现 - 实际上
r
的真实大小和一个指针的大小一样,并且有自己独立的地址,所有的编译器对待&
都是通过指针的方式来实现的,所以在函数传递值时,如果传递引用的话,就只需要传 4 个字节(32 位环境下)
7. Refernce Usage,引用与函数签名
- Reference 很少用来声明变量
- 函数参数传递
Q:为什么上图的二者不能同时存在? A:因为他们的函数签名相同,函数签名包括函数名称和参数列表和其后的
const
,不包括返回值,变量前有没有&和const
都为相同的签名。 这两者当然是不能同时存在的,如果同时存在的话,这两个函数都可以接受 double 类型的变量,那么编译器将不知道imag(im)
该调用那一个,所以这是不能同时存在的。
七、对象模型(Object Model)
1. Vptr and Vtbl,虚指针和虚表
- Vptr 和 Vtbl 在代码层面上是看不到的
- 只要 class 中存在 virtual function,一个也好,一万个也好,class 的 object 中就会有一个指针
- 这个指针指向 Vtbl,Vtbl 存有虚函数的地址,编译器在处理虚函数时就会根据这条路径来
- 容器内想要存放各种各样的子类的话,就需要将容器的元素指定为父类指针,因为容器只能存放相同大小的元素
- 而父类指针想要调用不同子类的同名函数
draw()
就需要父类将draw()
写成 virtual 的,以实现多态
3. Dynamic Binding,动态绑定
- C++ 编译器看到一个函数调用,他有两个考量,他是将他动态绑定,还是将他静态绑定。
- 静态绑定就是被编译为 Call xxxx(函数地址),动态绑定就是被编译成
(*p->vptr[n])(p)
这种样子,具体调用谁要看 p 指向 a 还是 b 还 c。 - 动态绑定的三个条件:通过指针调用,指针向上转型,虚函数
4. Const,常量
- 当成员函数的 const 和 non-const 版本同时存在,const object 只会(只能)调用 const 版本,non-const object 只会(只能)调用 non-const 版本,这就解释了为什么,函数末尾的 const 是属于函数签名的。
- const 该加就要加,菜鸟才会一个 const 不写
std::basic_string<...>
经过 typedef 后就是标准库中的 string Q:为什么这里要设计两个这样的函数,一个带 const,一个不带 const 呢? A:我们所使用的 string 是一个 reference counting 计数的技巧,相同内容的字符串是共享的,比如拷贝 3 个 string,那么这 3 个 string 和原字符串互相共享同一个字符串内容。 既然是共享内容的,那么就涉及到一个数据变化的问题,假如原字符串要改,那么就应该复制一份不共享的给他改,而不影响其他之前复制的字符串。oparator[]
就可能更改字符串的内容,所以如果operator[]
操作要改字符串的话,就需要做 copy on write 这个动作。如果不改(比如调用者是 const)则就不需要,所以要实现两个这样的函数。
这里又涉及到一个问题: Q:non-const object 如果调用
operator[] const
怎么办?这样不就没有 coyp and write 的过程了吗? A:当成员函数的 const 和 non-const 版本同时存在,const object 只 会(只能)调用 const 版本,non-const object 只会(只能)调用 non-const 版本,repeat again。这是 Cpp 考虑到这种情况后规定的。
八、内存管理(Memory Management)
1. Overload ::operator new,::operator delete,::operator new[],::operator delete[]
- 我们使用的 new 和 delete 都是重新封装过的,编译器会编译成 operator new 和 operator delete,我们可以重载 operator new 和 operator delete 来进行内存管理
::
表示全局的,global,重载这些也需要实现原本 malloc 和 free 的功能,然后再添加别的功能- operator new 的重载的第一个参数必须是 size_t 类型,返回值必须是 void* ,其他几个也是一样
2. Overload operator new, delete Using Member Function
- 编译器编译 new 和 delete,实际上 operator new、delete 只其分配内存的作用,传入的参数大小(分配的空间大小)都由编译器来推算,我们也只需要重载这两个函数分配内存的作用就行了
- 编译器编译 new[] 和 delete[]
Q:编译器为什么要把 operator new[]的申请空间要 +4 呢? A:从上面两图可以看到,operator new 和 operator new[]的实现没有什么区别,都是传入一个 size_t 的参数,但是 operator new[]传入的参数还额外申请了 4 个字节的空间 (一个整数的空间),就是为了存储 N 的大小,方便之后调用 N 次 ctor 和 dtor
- 对于以 member function 重载的 operator new 和 operator delete 们,可以以::的方式绕过重载,使用全局的 operator new 和 operator delete,如上图
3. New[] 的内存分析
class Foo{
public:
int _id;
long _data;
string _str;
public:
Foo():_id(0) {cout<<"default ctor.this="<<this<<" id="<<_id;}
Foo(int i):_id(i){cout<<"ctor.this = "<<this<<" id="<<_id;}
//virtual
~Foo() {cout<<"dtor.this="<<this<<" id="<<_id;}
static void* operator new(size_t size);
static void operator delete(void* pdead, size_t size);
static void* operator new[](size_t size);
static void operator delete[](void* pdead,size_t size);
};
void* Foo::operator new(size_t size){
}
- Foo object 的数据部分占 12 个字节,int、long、string 各四个字节(32 位下)
- 如果 Foo 中有虚函数,每个 Foo object 则还会多 4 个字节,用于存储 Vptr,也就是指向 Vtbl 的指针,占 16 个字节
- 仔细看上图,ctor 和 dtor 调用的次数,和调用他们的地址,可以看到使用 ctor 和 dtor 的顺序是反的,而且 new[]时,预留了 4 个字节的空间来放 size,第一个 Foo object 的地址比申请来的空间的首地址要大 4。(每一个地址标记的空间的大小为 1bytes)
4. Overload placement operator new(), delete()
- operator new() 的返回值必须是 void*,第一个参数必须是 size_t 类型
- 也有人说其余的参数必须有指针才能叫 placement new,公说公有理,婆说婆有理
- placement delete 只有当对应的 new 所调用的 ctor 抛出 exception,才会被调用
- 即使 placement delete 和 placement new 为一一对应也能编译通过,只是会有 Warning
Q:为什么 5 号 ctor 抛出异常后程序结束了,而没有调用对应的 place ment operator delete? A:异常抛出后会一层一层传递,直到最后的阶段,如果还没有人处理,程序就会调用一些函数将程序结束掉。这里很奇怪,在程序结束之前,对应的 placement operator delete 应该是会被调用的,在 G4.9 里没有调用,在 G4.2 里确实调用了,所以这个和编译器有关