C++笔记
C++面向对象高级编程
防卫式声明
1 |
|
内联函数
如果函数在class body
内定义完成,便自动成为inline
候选人
构造函数初始化
尽量使用initialization list
初始化
一个变量数值的设定有两个阶段,初始化阶段和赋值阶段。initialization list
是第一阶段设定,在花括号内用=
符号赋值是第二阶段
1 | class Rectangle |
参数传递
传引用,返回引用,如果可以的话
1 | inline complex& |
传递者无需知道接收者是以reference
形式接收
const成员函数
总是应该考虑类的成员函数是否改变类的数据成员。若不改变,应显式地用const
关键字限制函数行为
1 | class person |
含有指针的类
如果指针是类的成员,则应仔细考虑拷贝构造函数,赋值函数和析构函数的编写,避免浅拷贝
1 | class String |
对于赋值运算符重载函数,一定要先检查是否自我赋值
1 | inline String& String::operator=(const String& str) |
array new一定要搭配array delete
就是说,用new
关键词动态分配了一个数组,释放内存时一定得用delete []
释放整个数组。如果没有加上中括号,可能会导致内存泄漏。
为什么只是可能?例如你动态分配N个某种类型的对象,它们是连在一起放在内存的某一块地方,负责管理的数据结构会记录这一块存的东西占了多大空间,存了多少个对象等等。所以当使用delete
释放内存时,无论加不加中括号,N个对象的空间都会被回收,内存泄漏并不是发生在这里。试想,若这N个对象每个也都动态申请了内存,那么每个对象死亡时按理应该调用析构函数释放内存。如果使用delete[]
,编译器知道释放的是一个数组,编译器会分别调用这N个对象的析构函数,确保每个函数动态申请的空间都被释放掉,没有内存泄漏;但如果没有加上中括号,编译器以为释放的不是数组而只是一个元素,于是只会调用数组里第一个元素的析构函数,剩余元素的析构函数没有调用,它们所动态申请的空间也因此没有释放,导致了内存泄漏。
综上,如果动态申请一个对象数组,如果每个对象并没有动态申请空间,那么就算释放数组时忘记加中括号也不会有啥实际问题,但若每个对象单独又申请了空间,那么除了第一个元素外,剩余元素的动态申请的空间都没有回收,造成了泄漏
构造与析构
构造由内而外,析构由外而内
C++程序设计兼谈对象模型
转换函数
转换函数能将一种类型的对象转换为另一种类型的对象,试想,你编写了一个分数类(Fraction),该类成员变量为分子和分母,将该类的对象转换为一个浮点数用于算术运算是否是比较合乎情理的呢?于是,我们在类中添加operator double()
转换函数,负责在需要时将该类的对象转换为double
类型的对象
1 | class Fraction |
在编写如上转换函数后,我们可以将此类对象直接用于算数运算
1 | Fraction f(3.0, 5.0); |
当执行d = 4 + f
语句时,编译器首先查看是否存在一个操作符重载函数,它的第一个参数是整数(或浮点数),第二个参数是Fraction
类型的对象,没有这么个函数。于是又找是否存在转换函数,将Fraction
类型的对象转换为double
类型对象,找到了,于是调用该函数,将其转换为一个浮点值参与运算
需注意,operator double() const
函数没有返回值
non-explicit one argument constructor
C++中既然存在上面提到的转换函数这种“把自己的类型对象转换为别的类型对象”的方法,也有“将其他类型对象转换为自己类型对象”的方法
1 | class Fraction |
首先观察该类的构造函数,有两个参数,但第二个有默认值,实际使用的时候可以只指明第一个参数值即可。可以说这个构造函数有两个parameter
,而只有第一个参数是没有指明默认值的(non-explicit one argument
)。再考虑d2 = f + 4;
,f
位于加号的左边,看起来有点满足Fraction
类的加号重载函数,但是加号重载函数要求的参数是一个Fraction
类的对象,而此时能作为参数的只有一个4
,那能不能考虑将4
从int
类型转换为Fraction
类型呢?整数4
可以看作分数4/1
,这符合我们的常识,编译器用Fraction
类的构造函数,将4
作为第一个参数,而第二个参数取默认值1
,将原本int
类的对象4
转换为一个Fraction
对象,于是加法得以进行
(以上解释是侯捷老师在课程中的解释,但说实话我还是不太明白编译器为啥就知道默默地去用构造函数来进行类型转换)
再看下述代码
1 | class Fraction |
这里main函数里的加法,将f
转换为double
类型对象也走得通,将数值4
转换为Fraction
类型对象也走得通,语句具有二义性,因此编译报错。但如果改变加法顺序
1 | Fraction d2 = 4 + f; |
分析知此情形只能是将f
转换为double
类型
explicit关键字
explicit的意思是“清楚明白的,明确的,详述的;直截了当的,坦率的”。将其用于修饰构造函数,告诉编译器,“这个构造函数只能用于它的本职工作——在创建该类对象时进行初始化,别偷偷拿去搞什么类型转换的事情”。
1 | class Fraction |
加上explicit
关键字后,由于参数类型不匹配,加号运算符重载这条路走不通了,因此f + 4
唯一合理的解释就是将f
通过转换函数转换为double
类型的对象
pointer-like classes
以智能指针为例
1 | template<class T> |
智能指针用起来得像一个指针,所以得重载以上两个运算符。值得注意的是->
运算符的重载,试想,若用户如此使用智能指针
1 | shared_ptr<Foo> sp(new Foo); |
实际上用户是想
1 | px->method(); |
但观察我们的->
重载函数发现,重载函数已经将->
运算符用掉了,让人产生疑惑。侯捷老师对此的解释是,->
运算符很特别,它作用下去得到的结果会继续作用下去,因此该运算法能继续为px
所用,上述的写法是行得通的
那不禁追问,为什么->
运算符就恰好能行得通呢?答案是:语言是人创造的,语言的创造者想到了要这么用->
运算符,就在底层实现了对如此使用的支持
function-like classes(仿函数)
说实话这一节学得并不很懂,一个仿函数的实例如下
1 | template <class T> |
使用它
1 | double d1 = 5; |
第一对括号是生成identity<double>
类型的临时对象,第二对括号里是函数的参数。语句将d2
的值设为5
function template 函数模板
1 | template <typename T> |
函数模板在使用时可以不指定类型(当然,指定也可以),编译器会自动进行实参推导argument deduction
1 | stone r1(2, 3), r2(1, 4), r3; |
member template 成员模板
一个模板内部又有模板,则里面嵌套这个就是成员模板。常用于构造函数
1 | template <class T1, class T2> |
试想这么一种情形:有两个基类,每个基类分别派生出一个派生类
1 | class Base1 {}; |
1 | pair<Derived1, Derived2> p; |
用对象p
作为参数构造p2
,p2
调用的是成员模板的构造函数,可以这么做吗?用派生类对象给基类对象赋值?可以,这称为向上造型up-cast
specialization 特化
特化与泛化整好相反,特化指定类型
1 | //这是泛化 |
关键词template
后面接一对空的尖括号
函数名后加上一对尖括号,尖括号中指定需要特化的类型
partial specialization 偏特化
有两种类型的偏特化
1)个数的偏
1 | //这是没有特化的模板 |
另一个例子
1 | //这是没有特化的模板 |
2)范围的偏
先看例子
1 | //没有特化 |
1 | C<string> obj1; |
使用时,若实际类型不是指针,则生成的是没有特化的类模板的对象obj1
,若类型是指针,则生成的是范围特化了的类模板的对象obj2
;注意,没特化的类模板和特化了的类模板看起来很像,但实际完全是两个东西,没啥关联
template template parameter 模板模板参数
一句话,一个模板的参数又是一个模板
1 | template<typename T, template<typename T> class SmartPtr> |
使用
1 | XCls<string, shared_ptr> p1; |
注意到模板的参数和模板的参数模板的参数都是T。这样,当模板的第一个参数T确定时,参数模板的参数也随之确定,很自然就有SmartPtr<T>
这样的用法
模板模板参数有什么用?见下面这个例子
1 | template<typename T, template<typename T>class Container> |
使用
1 | template<typename T> |
第一个参数决定存储数据类型,第二个参数决定用于存储的容器类型
看另一个例子
1 | template<class T, class Sequence = deque<T>> |
使用
1 | stack<int, list<int>> s; |
注意,这个例子不是模板模板参数,即其第二个参数不是模板。在使用stack
时,第二个参数要显示指明list
存储的对象类型,直接绑定死了,但之前例子里的模板模板参数在使用时第二个参数是不显式指定容器的参数类型的,而是在模板代码里绑定
variadic templates 数量不定的模板参数
1 | void print() {} |
使用
1 | print(7.5, "hello", false, 42); |
两点值得注意:1)typename...
,Types&...
以及args...
,三个点是语法的一部分,表示数目不定(0个或多个),如果想知道可变参数的个数可使用sizeof...(args)
;2)注意到代码第一行有一个无参数的print()
函数。当第四行有参数的print()
不停递归调用自身时,每递归一次参数减少一个,当参数数目为零时不满足第四行的print()
的调用要求(第四行这个函数要求至少有一个参数)。因此,若不写一个无参的print()
,则编译出错
令我有点困惑的是在使用时不指明模板类型,而是直接传递函数参数调用函数,由函数参数类型自动倒推模板参数类型?虽然确实可以运行得到正确结果,还是不太明白背后的原理
reference 引用
实际上,引用底层是用指针实现的,但它给人一种假象,似乎它真就是引用对象的别名
定义一个对象int x = 0;
,再用一个引用对象引用它int& rx = x;
,对象和引用实际储存在内存里两个不同的位置,而且由于引用底层是指针实现的,理论上它的大小是该机器上指针大小(一半4字节或8字节),但对象的大小可大可小,没有限制。所以理论上对象和引用的大小可以不同,地址也不同。但是,当你使用
1 | sizeof(x) == sizeof(rx); |
进行比较判别是,会发现这两个判等表达式总是为真。故引用提供了一种假象,好似真的只是别名,让用户用起来十分便捷。引用的一个较大用处就是函数参数可以设置为传引用。的确,引用能实现的直接用指针也能实现,但用引用实现更加优雅
1 | void func1(Cls* pobj) { pobj->xxx(); } |
以上述代码为例,三个函数分别是传指针,传值和传参数。观察发现,传值和传引用接口相同,而传指针较为特别
需注意
1 | double imag(const double& im) { ... } |
此两函数的函数签名signature
被视作相同,故不能同时存在
1 | double imag(const double im) const { ... } |
对于这两组函数中的一组,在函数名,参数表均相同的情况下,一个函数用const
修饰,另一个没有,故其函数签名不一样,可同时存在。老实说为啥有无const
能区分两个几乎一模一样的函数让我感到很疑惑,我也想不到合适的例子说服自己,但侯捷老师说它们不同,我也只好先保留疑惑
2021/12/5 更新:疑惑已解决,见”谈谈const“小节
关于vptr和vtbl
父类有虚函数,子类必然继承父类的虚函数。一个类有虚函数意味着什么呢?从内存的角度看,一个类有虚函数,意味着这个类存在一个虚指针virtual pointer
,所以即使一个类除了虚函数其他啥数据也没有,用sizeof()
去测这个类的对象的大小,会得到4或8,即一个指针的大小。虚指针指向一个虚函数表virtual table
。虚函数表里是该类从父类那里继承而来的各个虚函数以及该类自己定义的新的虚函数(如果有的话)
注意,假设class A有两个虚函数func1(), func2()
,class B继承自class A,而 class C又继承自class B。自然,这三个类都有自己的虚指针,但是三个类的指针指向的是三张不同的表。假如class B只重写了func1()
,没有重写func2()
,那么在class B的虚函数表里,一个表项指向的是原本class A的func2()
的地址,另一个表项是指向一个新的、class B自己重写的func1()
的地址。class B的func1()
和class A的func1()
是两个完全不同的函数。同理,若class C也是只重写了继承自class B的func1()
,却没有重写func2()
,则在class C的虚表中,一个表项指向class C自己的fun1()
的地址,而另一个表项则是指向继承自class B的func2()
,而class B的func2()
其实也是从class A继承而来,因此,实际上class C的虚表的这个表项实际上最终是指向class A的func2()
的地址。故在此例中,实际上只有四个虚函数:class A的func1(), func2()
,class B的func2()
和class C的func2()
。三张不同的虚表,而每张虚表都有一个表项是指向class A的func2()
的地址
我们可以用父类的指针指向子类的对象,因为子类对象是一个父类对象。例如有animal
这个类,而它有dog
这么个子类,dog
当然是animal
,因此可以按如下方式使用,称为up-cast
1 | vector<animal*> myVector; |
而animal
不一定是dog
,因此不能用dog
类型的指针指向animal
类型的对象
如果animal
类有一个虚函数func1()
,dog
类重写了这个函数。当我们用animal
类的指针指向dog
类的对象,并通过这个指针调用func1()
函数时,我们其实希望调用的是dog
类的func1()
函数,因此,编译器不能看到指针类型是animal
就直接调用animal
类型的func1()
,而应分析指针指向的对象实际是什么类型,调用实际指向的对象类型的func1()
。这称为动态绑定dynamic binding
,传统C语言那种函数调用称为静态绑定static binding
关于Dynamic Binding
注意,只有涉及指针时才考虑是否动态绑定,例如假设class A有虚函数func()
,class B继承自class A,并且class B重写了func()
1 | B b; |
这不是用A类型的指针去指B类型对象,而是把B类型对象强制转换为A类型对象。因此,如果通过a去调用虚函数,调用的是A::func()
,但如果
1 | B b; |
这种情况下,则调用的是B::func()
谈谈const
这一小节解决了之前遗留的一个疑惑:两个同名同参数的函数,一个用const修饰,一个没有,它们的函数签名是不一样的,即它俩可以并存,之前我就一直搞不懂那我调用时调用的是其中哪一个
注意,首先这里讨论的是类的成员函数,其次,上文所说的用const修饰是指
1 | class A |
而不是
1 | class A |
一个成员函数用const修饰代表它一定不会修改该类的成员变量,而不加const则说明它可能改变成员变量。因此用const修饰的成员变量只可能被同为const修饰的成员函数调用,而不可能被没const修饰的成员函数调用。但如果成员变量本身不是const修饰的,那无论成员函数是否有const修饰都可以调用它。这就存在我之前所产生的疑惑:当两个同名同参数函数同时存在,其中一个是const,另一个是非const,那么在调用时如何知道调用的究竟是哪个呢?
答案见图中上方小字:当两个版本的成员函数同时存在时,const object调用const版本的函数,non-const object调用non-const版本,这就解决了我的疑惑
这个知识点的用处何在呢?阅读上图右方的代码,basic_string是string底层的实现。我们可以有多个string对象,它们实际指向同一地址的字符串,以到达节约空间之效,而当某对象欲修改字符串内容时,为避免其行为影响到其他对象,因此必须进行写时复制。也就是说,平时通过[]
运算符取字符串内容时,我们调用图右侧上方const版本的函数,而当通过该运算符修改字符串内容时,调用图右侧下方的non-const版本的函数,可在该函数中对字符串进行写时复制,避免错误发生
C++内存管理
(本节图片均来自于侯捷老师课件)
初次接触operator new/delete, new handler, placement new/delete这些概念的时候是学习Effective C++时候,当时看得云里雾里的,根本不明白为什么一个new/delete操作要弄得这么复杂。今天(2022/4/18)看了侯捷老师的C++内存管理课程后,才终于对这些概念有一点感觉了。
关于动态内存的申请与释放,C有malloc()和free()。malloc()需要一个size_t类型对象作为参数——你给它一个欲申请空间大小的值,它尝试在堆里找一块可用内存,将其首地址作为malloc()返回值返回。而当欲归还之前所申请的空间时,将指向欲归还内存的首地址的指针作为参数调用free()函数即可释放内存。
C++有new和delete,先说new。
1 | Complex *pc = new Complex(1, 2); |
上面这个new expression可以分解为以下三个步骤:
1 | void *p = operator new( sizeof(Complex) ); // allocate |
首先要在堆里申请一块内存,然后调用对象的构造函数进行构造。
申请内存实际由operator new()函数负责,其可能的逻辑如下:
1 | void* operator new(size_t size, const std::nothrow_t&) |
函数第二个参数const std::nothrow_t&指明:若尝试申请动态内存失败,该函数不抛出异常而是返回0.
每当我们尝试new一个对象的时候,我们实际做了两件事:申请对象需要的空间,然后调用构造函数进行对象的构造。申请内存由operator new()函数负责,(在标准实现中)该函数通过一个while循环不断地调用malloc()函数尝试申请所需的空间,若申请失败,则调用new_handler()函数进行处理,反复尝试直至申请内存成功或抛出异常。
我们发现,上述operator new()函数有不止一个参数,而我们一般情况下调用new时,只传给其一个参数:对象的大小。实际上,我们可以重载出多个不同的operator new()函数,它们只需要满足:
- 参数表不同
- 第一个参数为size_t类型
这便是placement new。有了placement new,还需要对应的placement delete。负责处理当对象构造失败时如何处理所申请的内存。
现在,假设放置对象所需的内存我们已经拿到了,接着就该调用构造函数进行对象构造,很遗憾,在构造过程中出现了错误,构造失败,于是我们处于这么一种境地:我们已经从堆中申请了内存,但由于构造对象失败,这片内存不能如我们预期所愿用来放置对象,换句话说,这片内存已经没用了。如果我们不能正确地将其释放,则会造成内存泄漏。如何归还该内存,便是placement delete的职责。