C++面向对象程序设计复习笔记(下)

C++面向对象程序设计复习笔记(下)

侯捷 C++面向对象程序设计的下半部分笔记

Object-Oriented-Programming

一、类型转换函数(Conversion function)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//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,类模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);
}
  • 类模板在使用时需要指明

2. Function Template,函数模板

1
2
3
4
5
6
7
8
9
10
11
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,成员模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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,模板特化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//模板泛化
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 会变成 list,这应该是行的通的,为什么行不通了呢?
因为 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(){}
  • 使用:使用时,因为是函数模版,所以无需指定类型,根据函数的内容,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[] 的内存分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 里确实调用了,所以这个和编译器有关

5. Basic_string 使用 new(extra)扩充申请量

作者

Meow-2

发布于

2021-09-15

更新于

2023-02-22


评论