effective C++ 读书笔记
不完整,不负责,仅供参考。
2021年6月16日
共识
- 声明 declaration
把型别名称告诉编译器 - 定义 definition
把声明的细节信息告诉编译器 e.g.int x;
这就是一个对象的定义式 提供了整形变量的地址可以说是构建了construtor (default constructor对应了参数的默认值)
若未定义默认构造函数却要产生一个对象数组 可以 先定义一个指针数组 再逐个用new进行赋值 而 copy constructor 则是 以值传递对象的代名词 - 初始化 initialization
通过调用constuctor达成 给对象赋予初值(要考虑引数的有效性) - 赋值 assignment
通过调用 operator= 来达成 给对象赋予新值(要释放旧的内存) - 客户 client
那些阅读、理解、使用、继承你的代码的人,包括了你自己
补充: String类
CSDN:c++中的string常用函数用法总结
之所以抛弃char*的字符串而选用C++标准程序库中的string类,是因为他和前者比较起来,不必 担心内存是否足够、字符串长度等等,而且作为一个类出现,他集成的操作函数足以完成我们大多数情况下(甚至是100%)的需要。我们可以用 = 进行赋值操作,== 进行比较,+ 做串联(是不是很简单?)。我们尽可以把它看成是C++的基本数据类型。
罗列如下:
a) =,assign() //赋以新值
b) swap() //交换两个字符串的内容
c) +=,append(),push_back() //在尾部添加字符
d) insert() //插入字符
e) erase() //删除字符
f) clear() //删除全部字符
g) replace() //替换字符
h) + //串联字符串
i) ==,!=,<,<=,>,>=,compare() //比较字符串
j) size(),length() //返回字符数量
k) max_size() //返回字符的可能最大个数
l) empty() //判断字符串是否为空,是空时返回ture,不是空时返回false
m) capacity() //返回重新分配之前的字符容量
n) reserve() //保留一定量内存以容纳一定数量的字符
o) [ ], at() //存取单一字符
p) >>,getline() //从stream读取某值
q) << //将谋值写入stream
r) copy() //将某值赋值为一个C_string
s) c_str() //将内容以C_string返回
t) data() //将内容以字符数组形式返回
u) substr() //返回某个子字符串
v)查找函数
w)begin() end() //提供类似STL的迭代器支持
x) rbegin() rend() //逆向迭代器
y) get_allocator() //返回配置器
新的转型运算符
static_cast<type>(expression) //接近传统C的转型动作:(type)expression
const_cast<type>(expression) //重要 可以将对象的常数性转掉
dynamic_cast<type>(expression) //?安全的向下转型动作
reinterpret_cast<type>(expression) //暂时没啥用
lhs 左端 rhs 右端 p pointer m member r reference
下面正式开始
内存管理
尽量以 const 和 inline 取代 #define
inline 函数即内联函数,把函数体直接替换函数名,对于小体量的函数来说可以减少栈的创建与删除,提高了计算机运行的效率。
用编译器取代预处理器 编译器看不见宏中的内容 符号表中不会出现 容易产生困惑
以下宏代码 看起来像函数又没有函数调用的成本 但缺点还是很多
#define max(a,b) ((a)>(b) ?(a):(b))
容易出现各种问题。因为编译器无法检查错误(于是选择inline函数+模板 完全实现相同的功能)
template<class T>
inline const T& max(const T& a, const T& b)
{return a>b ? a : b;}
尽量以 new和delete 取代 malloc和free
这是因为malloc和free就是纯粹的增删内存空间,他们对于constructor和destructor一无所知。
还学到了 “未定义”的问题 在编译测试阶段是不会报错的,只有在运行时才会检测出来 比如用free来清除 new生成的指针
使用相同形式的new和delete
e.g
string *stringArray = new string[100];
...
delete stringArray;
出现 行为未定义 错误
(👆只调用了stringArray[0]的析构函数)
delete []stringArray
则是删除指针数组
不加中括号 则只删除单一对象
2021年6月17日
记得在destructor中以delete对待指针
当class开始成长、维护和强化,以下几件事情需要配合着去做好。
- 在constructor中初始化指针,如果暂无内存分配,也要初始化为0(Null),避免野指针的出现。
- 在assignment运算符重载中删除旧有的空间,配置新的空间。
- 在destructor中删除这个指针。(但不要轻易删除一个传递而来的指针)
内存泄漏(Memory leak)
程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
内存泄漏缺陷具有隐蔽性、积累性的特征,比其他内存非法访问错误更难检测。因为内存泄漏的产生原因是内存块未被释放,属于遗漏型缺陷而不是过错型缺陷。此外,内存泄漏通常不会直接产生可观察的错误症状,而是逐渐积累,降低系统整体性能,极端的情况下可能使系统崩溃。
为内存不足的状况预做准备
operator new 无法配置出需要的内存时,会抛出exception异常。
assert函数
原型
#include <assert.h> void assert(int expression);
若表达式expression结果为0,则打印一条错误信息并调用abort终止程序运行。
return返回,可析构 main或函数中的局部变量,尤其要注意局部对象,如不析构可能造成 内存泄露。
exit返回不析构main或函数中的局部变量,但执行收工函数, 故可析构全局变量(对象)。
abort不析构main或函数中的局部变量,也不执行收工函数,故全局和局部对象都不析构。
C++是建议多使用return的。
本节讲了一个new_handler函数的东西,但我觉得好多且暂时不太实用,先放着。
下面两个条款是关于重载new和delete函数的,也先放着。
避免遮掩了new的正规形式
重载了new就应该也重载delete
构造函数 析构函数 和 assignment运算符
几乎每个类都包含以上三者,设计一个具有良好结构的class骨干。
如果class内动态配置有内存,为此class配置一个copy constructor 和 assignment运算符
想必是为了避免浅拷贝的出现。
e.g.
class String
{
public:
String(const char* value);
~String(); //没有定义copy cstor 和 operator=
private:
char *data;
};
String::String(const char* value)
{
if(value)
{
data = new[strlen(value) + 1];
strcpy(data, value);
}
else
{
data = new char[1];//单一元素也用数组来定义,为了析构函数能普适
*data = "\0";
}
}
inline String::~String() {delete [] data;}
int main()
{
String a("hello");
String b("world");
a = b; //系统调用默认assignment运算符,b.data直接指向a.data的区域。
String c = a;//系统调用默认copy construcor,c.data也指向a的区域。
void donothing(String localstring){}
String s = "Nice.";
donothing(s);
return 0;
}
以上操作引发两个问题:
- b原来的内存泄漏,析构函数没有被调用,但这个空间永远找不回来了。
- a,b指向相同空间,当其中一个的析构函数被调用,另一个的指针指向的空间值也会受到影响。
在传值的时候,系统调用默认copy constructor 来构建local string,localstring和s指向同一字符串空间。当donothing函数体结束,localstring被析构,s指针也指向了一块被删除的内存。
哪怕 s此后不再被使用也有问题,当 s 被析构时,会产生同一片空间被删除两次的未定义问题。
所以如果class中含有任何指针,请定义相应的copy cstor和assignment
在constructor中尽量以initialization动作取代assignment动作
即尽量使用初始化列表。
构造函数分为初始化 和计算两个阶段。
因为 const member 和 reference member 只能通过初始化列表进行初始化,不能被赋值。
template <class T>
class NamedPtr
{
public:
NamedPtr(const string& initName, T *initPtr);
...
private:
const string & name; //引用成员只能通过初始化列表赋值
T * const Ptr; //常成员也是
}
同时,initialization更加高效,只调用一次成员函数,constructor(initname,initPtr)。
assignment却需要先调用default constructor, 在调用assignment运算符。
关于 reference members
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xnLezZYo-1627285478463)(C:\Users\Yu.Y\AppData\Roaming\Typora\typora-user-images\image-20210617165833365.png)]
2021年6月18日
初始化列表中成员的初始化次序应该和其在class里的声明次序相同
定义一个有任意边界的数组类 Array class template
template <class T>
class Array
{
public:
Array(int lowbound, int highbound);
...
private:
vector<T> data; //把数组数据储存在一个vector对象中
size_t size;
int lbound, hbound;
};
Array<T>::Array(int lowbound, int highbound):
size(highbound-lowbound+1), lbound(lowbound), hbound(highbound), data(size)
{}
这段代码无法构建一个 长度为size 的vector容器。这是因为成员的初始化次序是基于class中的声明次序的,所以应该让初始化列表和class声明次序相对应。
Vector容器
可以理解为一个可存放任意类型的动态数组。类似于python中的列表。
具有增删插入查找等完备的功能。
总是让基类拥有virtual distructor
总的来说虚析构函数是为了避免内存泄露,而且是当子类中会有 指针成员变量 时才会使用得到的。也就说 虚析构函数使得在删除指向子类对象的基类指针时可以调用子类的析构函数达到释放子类中堆内存的目的,而防止内存泄露的。
否则基类指针调用的是基类的析构函数,则产生了内存泄漏问题。
但当class不企图作为base class 的时候,令destructor作为虚函数也不一定好。
因为欲实现虚拟函数,对象必须夹带一些额外信息,用以在执行时期协助决定“哪一个虚拟函数应该被调用”。大部分编译器通过vptr(virtual table pointer),一个指向由函数指针组成的数组(vtbl virtual table)的指针,来实现。每一个带有虚函数的class都有一个相应的vtbl,编译器在其中寻找相对应的函数指针。这会导致派生对象的大小翻倍。
虚函数的地址一定是在 vtbl 中排列的。
当class内含有至少一个虚函数时,我们才将其destructor声明为virtual。
抽象类的纯虚析构函数被调用时,也需要提供一个函数实体。
virtual ~AMOV() = 0;
AMOV::~AMOV() {} //可以什么都不g,但必须定义一个函数实体才能过编译。
令operator= 传回 “*this 的 reference"
C& C::operator=(const C&)
传入值是一个引用,传出值也应该是一个引用。
常见错误1 void operator=(const String& rhs)
这会妨碍assignment串链的形成。
常见错误2 const C& operator=(const String& rhs)
这是为了防止以下行为
(C1 = C2) = C3
但事实上内建型别是允许这么做的。
一个缺省的assignment运算符,传回值有两种选择。
*this 和 rhs(即传入的参数)
传回rhs的那一版无法编译,因为rhs是 const 而 operator= 的类型不是。
如果把 rhs 改成 String& rhs
则 client 无法过编译。因为 编译器需要先调用String的构造函数把char* 转换为String的暂时对象,而这个对象是const的。这能够避免暂时对象被传递给一个会修改参数值的函数。
*所以只能 return this
把operator= 型别设定为引用时,返回的*this也是引用值,不会调用复制构造函数传值。
不设定为引用,则会调用。
传回rhs的时候过不了编译。
在operator= 中为所有的data members 设定赋值内容
这好像没什么好说的。
但:一个派生类的 assignment运算符 有义务处理其基类成员的赋值,哪怕是private成员。
但在operator= 函数体中直接做基类private成员的赋值动作是不合法的.
可以这么做:
Derived& Derived::operator=(const Derived& rhs)
{
if (this == &rhs) return *this;
Base::operator = (rhs);//调用基类的赋值函数
y= rhs.y; //对派生类的私有成员赋值
return *this;
}
在 copy constructor 中也是同理。
可以这么写
class Derived: public Base
{
public:
Derived(const Derived& rhs):Base(rhs),y(rhs.y){}
//直接使用了派生类与基类兼容的复制构造函数。
};
在operator= 中检查是否“自己赋值给自己”
为什么要在assignment运算符中谨慎处理可能发生的别名问题?
- 效率
- 在为左侧对象赋值时,我们需要先释放其旧有内容,“自己赋值给自己”可能导致旧资源的丢失。
做以下检查就足够
if(this == &rhs) return *this;
总之,对于pointers和references,对于别名问题总是要小心。
2021年6月21日
类与函数之设计与声明
声明了一个新类,就是产生了一个新的型别。
要设计一个高效率的class,需要面对一下问题:
- 对象要如何产生和销毁?
- 对象的初始化和赋值动作要如何进行?
- 对象以传值方式传递给新的型别,是什么意思?(copy constructor)
- 这个类的合法值的规范是什么?
- 这个类存在于继承体系中吗?如果是,声明的函数是否为virtual?
- 型别转换是显式的还是隐式的呢?
- 什么样的运算符和函数对于新型别是合理的呢?
- 禁止什么标准运算符和函数呢?可以把它们声明为private
- 谁有权力使用这个类的成员呢?(public/protected/private/friend)
- 新型别一般化的程度有多高?定义一个class还是一个class template呢?
- ……
努力让接口完满且最小化
客户端接口(client interface) 中一般只有函数。但要哪些函数不要那些函数呢?
大量的class接口容易令人迷失,同时可维护性较差(头文件过长)
一个广被执行的工作,可以因为变成成员函数而大幅提升效率、防止client发生错误,则将其纳入接口中就很合理。
区分成员函数、非成员函数和友元函数
原来成员函数和非成员函数的最大区别是 成员函数可以是virtual
如果要进行动态绑定,函数就必须是class member。
e.g.
class Rational
{
public:
Rational(int numerator = 0, int denominator = 1);
int numerator() const;
int denominator() const;
private:
...
}
现在要加入运算法则,应该声明为什么函数呢?
如乘法:const Rational operator*(const Rational& rhs) const;
疑问:以传值的方式传回一个const, 但接受的是一个传址的const引数
result = rational_object * 2; //可以!
result = 2 * rational_object; //不行!
当用这个乘上int时,只有对象在乘号左边才符合规范。这就不满足乘法交换律。
这里发生了隐式的型别转换 int → temp Rational (要有non-explicit construcor)
但只有在成员函数 operator* 的参数表中的数字可以被识别和转换。
因此我们选用non-member function,这样两个引数均出现在参数表当中了。
是否应该使用friend函数?在这里可以使用私有成员公开接口完成乘法运算,故不必声明为friend。只要能避免友元函数,就尽量避免。
还有,不要让operator >> 和 operator << 成为 members, 因为它们必须出现在对象的右边,和我们平时的习惯不符。
避免将data members 放在公开接口中
可以更加精确掌控data members 的可存取性。
👆通过函数存取其值,可以实现“不处理”,“只读处理”,“读写处理”,“只可写入”,四种形式。
最关键的:函数抽象性 可以机动地通过调用函数来管理数据。
尽可能使用 const
const 可以让你表达“某个对象不应该被修改”,从而获得编译器的帮助。
char *p = "Hello";
const char* p = "Hello"; //数据是常量
char* const p = "Hello"; //指针是常量
const char* const p ="Hello";
在一个函数声明式中,const可以用来修饰函数的传回值、个别参数、以及整个函数(仅对于成员函数成立)用 const 修饰函数的传回值,可以避免client出错,提高数据的安全性同时不会降低效率。
常成员函数内部不允许进行数据修改,常对象被定义以后就不能再做改变。
非常对象可以调用常成员函数,但常对象不能调用非常成员函数。
可以声明同名的 常成员函数 和 非常成员函数 从而根据传参的不同版本给出不同的返回值。
char& operator[](int position)
{return data[position];}
const char& operator[](int position) const
{return data[position];}
👆如此则对于不同的对象均有相应的解决方法。
注意!non-const operator[ ] 的传回类型必须是一个指向char的reference,不可以直接是个char。否则如 s[0]='x'
就无法编译。
- 因为对于 传回值 是内建型别的函数,修改传回值是绝对不合法的。?
纵使合法,C++以传值方式传回对象,意味着被修改的是s.data[0]的副本,而不是s.data[0]本身。
2021年6月22日
尽量使用传址,少用传值
C语言中每样东西都是传值,而C++也默认了传值是缺省行为。
在一个成员函数中传值(缺省)本质上是对对象调用了复制构造函数,成本很高。
通过传址来构建函数接口,效率会高很多。
const student& returnstudent(const student& rhs)
使用传址就是意味着传递一个指针,与虚函数搭配可以合理使用多态性。
void show(const Window& w)
实际上w是window的派生类也可以show。
传址的缺点是会导致别名问题,很难把控。
而对于一些小对象 比如int,传值比传址效率更高,因此不能一概而论。
当你必须传回object时,不要尝试传回reference
因为这会导致references指向一个已被析构的对象。
记住,所谓reference只是一个名称,一个既有对象的名称。任何时候看到reference,都应该立刻问自己,它的另一个名称是什么。
- 但是不用传址 又会导致构造函数和析构函数被调用。比起引用空悬和内存泄漏问题,构造和析构的成本简直不值一提。
还有人提出了比较有趣的想法:static变量。
但这会导致的问题是,在多次调用这个函数以后,static 变量是相通的。难以控制。
避免对指针型别和数值型别进行重载
短时间内我应该不会遇到这个问题。
防卫潜伏的ambiguity(模棱两可)状态
C++相信潜在的ambiguity不是一种错误。
e.g.
class B;//前置声明
class A
{
public:
A(const B&); //A可以由B构造出来
};
class B
{
public:
operator A() const; //B可以被转换为A,转换函数的重载声明
}
void f(const A&);
B b;
f(b); //会发生什么?
两种方法一样好,编译器拒绝做出表态。
void f(int);
void f(char);
double d = 6.02;
f(d);
还有多继承继承的函数同名问题,也潜伏着模棱两可的可能。
👆private继承的存取限制也不会影响编译器对模棱两可的判断。
解决方法:保持警惕,通过转型、虚函数等方式避免。
不想使用编译器暗自产生的member functions, 就明白拒绝它
大部分函数只要不放进class里就不会存在。
但assignment运算符如果没写,会由编译器自动产生。
可以将operator= 声明为private 则编译器不会生成默认函数,用户也无法调用它。
但在成员函数和友元函数中依然可以调用这个operator =,那就只声明,不定义,这样在你写代码的时候就会报错,从而避免错误产生。
2021年6月23日
尝试切割全局命名空间
什么是命名空间?
您可能会写一个名为 xyz() 的函数,在另一个可用的库中也存在一个相同的函数 xyz()。这样,编译器就无法判断您所使用的是哪一个 xyz() 函数。
因此,引入了命名空间这个概念,专门用于解决上面的问题,它可作为附加信息来区分不同库中相同名称的函数、类、变量等。使用了命名空间即定义了上下文。本质上,命名空间就是定义了一个范围。
在出现模棱两可的问题时,需要限定命名空间。
(在编译器还没有加入namespace时)若要仿真namespaces,可以产生一个struct/class来放置你的全局变量,并将它们声明为static members.
struct sdm
{
static const double Book_version;
class Handle {...};
static Handle& GetHandle(); //静态接口
}
//引用命名空间内的全局变量
typedef sdm::Handle Handle; //typedef方法,使得Handle成为sdm::Handle的同义词。
const double& Book_version = sdm::Book_version;
但是其实可以直接自行定义namespace,这样一些运算符重载可以直接以运算符的形式出现,而不必以函数的形式出现。
类与函数之实现
本章主要介绍template、class和function如何高效且合理地实现。
避免传回内部数据的handles
假设B是一个 const String object
class String
{
public:
String(const char *value);
~String();
operator char*() const;
...
private:
char *data;
}
const String B("Hello World");
B是一个const,我们便希望其中的data永远是Hello World。
但不排除有人会这样转换掉B的常量性。
String& alsoB = const_cast<String&>(B);
还有这种方法:
char *str = B;
strcpy(str, "Hi mom.");
这是否会导致data改变就取决于char*()
的重载细节了。
一个快速却不正确的实现:
inline String::operator char*() const
{return data;}
这是一个传回指针的函数!一个连接私有成员的接口。对str的任何修改都会导致*data的改变。如此,即使B被声明为了const,其中的数据成员也可以被我们通过这个const函数改变。
因此需要避免传回一个指向内部资料的char*()👇
inline String::operator char*() const
{
char *copy = new char[strlen(data)+1];
strcpy(copy, data);
return copy;
}
代价:慢。且函数调用者需要记得删除这个指针。
另辟蹊径:传回一个const char* 的指针
inline String::operator const char*() const {return data;}
传址的所谓 ”常函数“ 也会出现以上问题。
- 以下一段没有看懂
const member functions 传回 handles 是不好的行为,因为他违反了抽象性。甚至对于non-const member functions 而言,传回handles也会导致麻烦,特别是当设计暂时对象时,handles可能变为空悬的,即传回了个寂寞。
避免写出一个成员函数,传回一个指向较低存取层级的成员的非常指针或者地址
较低存取层级其实就是protected和private的意思。我觉着这就是我上一个条款所理解的内容。。
可以介绍一下指向成员函数的指针👇
class Person; //前置声明
//PPMF = "Pointer to Person member function"
typedef void(Person::*PPMF)();//这一句的意思是:PPMF是Person的一个成员函数类型,其具体类型是void*(),内部没有参数
class Person
{
public:
static PPMF verificationFunction() //全局变量函数,这里PPMF可以转换为上面的typedef
{
return &Person::verifyAddress;
}
private:
Address address;
void verifyAddress();
}
于是万恶的client们便可以这么做
PPMF pmf = chenyy.verificationFunction();
(chenyy.*pmf)(); //间接地调用了verifyAddress()
所以如果非要这么干,还是传回一个const object的指针或者reference吧。
2021年7月19日
千万不要传回“函数内local对象的reference”或“函数内以new获得的指针所指的对象”
前者是因为 一离开函数的scope其local对象就会被析构。
后者是因为根本不会有人记得在函数体以外delete
掉它。
尽可能延缓变量定义式的出现
即有需要时才去定义变量。
任何一个变量在定义时都会面临构造和析构的成本。
同时,及时对变量赋值,避免无意义的default constructor
。
明智地运用inlining
-
利:免除函数调用成本。
-
弊:导致目标程序过大。
如果inline
函数的函数体很小,以函数体所产出的代码比函数调用所产出的代码更小。则将函数inline化可以提高效率。
inline
指令只是对编译器的一种提示,编译器可以自由决定是否要忽略你的inline
指令
大部分编译器会拒绝将内含循环或者递归的函数inline化,对于任何virtual
函数也不会inline。
inline
的函数定义几乎总是在头文件中,这样其他源文件就可以方便地调用它。
函数体为空的构造函数和析构函数其实也包含着大量代码,如下:
Derived::Derived()
{
//配置heap上的内存
if (this object is on the heap)
this = ::operator new(sizeof(Derived));
Base::Base();
dm1.string();//构造函数
}
用户不能写出以上的代码,但编译器可以。
因此对于constructor
和destructor
的inline
也是不明智的。
将文件之间的编译依赖关系降至最低
把类的声明与定义分离在头文件和实现文件当中。这项分离技术的关键在于以“对class声明的依存性”来取代了“对class定义的依存性”。
因此而产生的牵连事项:
- 如果引用或者指针可以完成任务,就不需要具体定义某型别的对象,直接定义该型别的指针即可。
- 如果能够,尽量以class的声明取代class的定义。产生一个
handle class
,把函数调用转交给相应的body classes
- 不要在头文件中再
#include
其他头文件,除非不这样就无法编译。
另一种做法:抽象类Protocol class
和纯虚函数。
- 这里的
Handle class
和Protocol class
其实并没有看太懂。
继承关系与面向对象设计
C++语言提供了很多不同的特性,但做的事情可能有些相同:
- 如果需要一群classes具有共享的性质,是使用
public Base class
呢?还是使用template
呢? - 如果class A根据class B来实现,A应该private继承B呢,还是A拥有一个型别为B的data member?
- etc
本章将集中精力解释这些不同特性的真正含义。
确定你的public inheritance,模塑出“isa”的关系
isa: “是一种”的关系。
如果class D 以 public 继承了 class B, 则每一个型别为D的对象同时也是一个型别为B的对象,反之不然。
D只是比B更加特殊化一点。因此,任何B派的上用场的地方,D就一定行。
但有时语文会影响类的定义。比如说:
鸟能飞。企鹅是一种鸟。但企鹅不能飞。
因此正确的isa关系应该是:
鸟。会飞的鸟能飞。企鹅是不会飞的鸟。
另一种方法,把企鹅的fly()
函数重新定义。
virtual void fly(){error("Penguins can't fly!");}
但最好还是在编译时就把错误识别出来。而不是在运行时才抛出错误。
区分接口继承与实现继承
接口继承 interface inheritance 实现继承 implementation inheritance
举个例子:
class Shape
{
public:
virtual void draw() const = 0;
virtual void error(const string& msg);
int objectID() const;
...
};
class Rectangle: public Shape {...};
class Ellipse: public Shape {...};
Shape
是一个抽象类,纯虚函数draw()
使它如此,因此不可产生Shape类的实体。client只能产生其derived class的实体,但Shape还是强烈影响了所有public继承它的classes。
因为:
-
Member Functions 的接口总是会被继承。对于Shape中的Member functions, draw是纯虚函数,error是非纯的虚函数,objectID是非虚函数。
-
声明一个纯虚函数的目的是让Derived Class只继承其接口,毕竟矩形和椭圆都能画出来,但绘画方式迥然不同。全是纯虚函数的类即
Protocol Class
有时是有用的,为其派生类只提供函数接口。 -
声明一个非纯的虚函数目的是让Derived Class继承其接口以及缺省行为。虚函数 的 接口也会被派生类继承,一般虚函数传统上会由基类提供一份缺省的实现代码,由derived class自行思考是否需要改写。
对于有可能忘记重新定义虚函数的问题有一种解决方法,在基类中定义纯虚函数,并在基类中protected定义一个
default
的缺省函数,只有派生类中明确要求用缺省方式,否则编译器会提醒需要实现纯虚函数。 -
声明非虚拟函数的目的是让derived class继承其接口及实现。在派生类中,此函数不会有不同的行为。
2021年7月25日
绝对不要重新定义继承而来的非虚拟函数
非虚拟函数和对象之间是静态绑定的,通过基类指针调用派生类的非虚拟函数会调用基类的同名函数。会出现精神分裂的情况。
适用于B对象的每一件事,都适用于D对象。而D一定会继承B对象的非虚拟函数的接口和实现。如果重新定义了非虚拟函数,则破坏了这种"D isa B"的关系。
绝对不要重新定义继承而来的缺省参数值
重新定义一个缺省参数值的办法就是重新定义一个继承而来的函数,鉴于上一条建议,此处仅考虑重新定义一个虚函数。
注意:虚函数是动态绑定的,但缺省参数值是静态绑定。
对象的**静态型别(static type)是程序声明它时所采用的型别。而动态型别(dynamic type)**则可以在程序执行过程中被改变。对象指针型别是静态的,但指针所指向的对象型别可以是动态的。则可能在调用D对象的虚函数时使用了B类的缺省参数值。
所以为什么C++会这么做呢?
如果缺省参数值是动态绑定,编译器就必须有某种办法,能够在执行时期为虚拟函数决定适当的缺省参数值。比起“在编译时就决定”,这显然更慢且更复杂,因此C++就进行了取舍。
避免在继承体系中做向下转型的动作
static_cast <SavingAccount*>(*p)->creditInterest();
👆通过转型static_cast
强行把*p
的类型向派生类转变(downcast)。
转型之于C++程序员,恰如苹果之于夏娃。
因为这把指针的普适性减弱了。如果后续又开发出了一个CheckingAccount
类型的账户,则你需要这么做。
if(*p points to a SavingAccount)
static_cast <SavingAccount*>(*p)->creditInterest();
else
static_cast <CheckingAccount*>(*p)->creditInterest();
一点也不优美,对于这种多态性,应该运用虚函数,并且让每一个虚拟函数有一个“无任何动作”的缺省实现代码,一边应用在并不像施行该虚拟函数的任何classes上。
注:有时如果必须进行转型,建议进行安全转型👇。
if(SavingAccount *psa =
dynamic_cast<Savingaccount>(*p)){ //这确实是一个变量的定义式,判断指针的动态型别是否与其转型目标一致。
psa->creditInterest(); // 若否,会传回null指针。
}
else {
error("Unknown account type.");
}
通过layering技术来模塑 has-a 或 is-implemented-in-terms-of 的关系
layering:我的理解就是类的嵌套。
通过类的嵌套可以明确地区分“is-a”和“has-a”的区别。
- 后面区分是一种和根据某物实现,没看懂。
区分inheritance和templates
在这两者之间选择需要思及“每一个class的行为”和“被处理对象的型别”之间的关系。
即:型别T会影响每一个class的行为吗? 如果不会,则template是好选择, 如果会,乖乖用继承和虚函数吧。
以下这个堆栈类还挺有趣的,应该是自定义了一个链表,假设对象型别为T。
template<class T> class Stack
{
public:
Stack();
~Stack();
void push(const T& object);
T pop();
bool empty(); //判断堆栈是否为空
private:
struct StackNode //节点(node)
{
T data;
StackNode *Next; //下一个节点
StackNode(const T& newData, StackNode *nextNode) //构造函数
: data(newData), next(nextNote){}
}
StackNode *top; //堆栈顶部节点
Stack(const Stack& rhs);
Stack& operator=(const Stack& rhs); //声明但不定义,阻止copying和assignment
}
Stack::Stack(): top(0) {} //将top初始化为null
void Stack::push(const T& object)
{
top = new StackNode(object,top); //将新节点放在list的前端
}
T stack::pop()
{
StackNode *topOfStack = top;
top = top->next;
T data = topOfStack->data;
delete topOfStack;
return data; //弹出最前端节点的data
}
Stack::~Stack()
{
while(top)
{
StackNode *toDie = top;
top = top->next;
delete toDie;
}
}
bool Stack::empty() const
{return top == 0} //妙用布尔值
👆可以在不知道T为何物的前提下编写每一个member function,即行为与型别无关。因此用类模板是很好的选择。
明智地运用private inheritance(私有继承)
总所周知,私有继承而来的任何members,在派生类中都无法访问。
private inheritance意味着只有实现部分被继承,而接口部分应该被略去。在“根据某物实现”这个层面上,我们尽可能使用layering,必要时才使用private inheritance。
要表达出“根据某物实现”的关系,layering和private inheritance都是有效的选择。
是的,完全没看懂,反正我是不可能private inheritance的。
明智地运用多继承
多继承导致了模棱两可,如果派生类继承了一个以上的基类,而它们又拥有同名的函数,则需要明白说出你要的是哪个member function。可以通过增加一对新的类为同名函数进行重命名,这个新名称有着纯虚函数的形式,所以将它们具象的subclass需要重新定义他们,于是同名的函数便被分割开来了。
一个钻石型继承体系则需要你面对一个问题,是否采用虚基类。
答案几乎总是“是”。但选择了虚基类,则需要面临程序执行空间和时间成本的增加。因为虚基类不是由对象本身来完成,而是由“对象指针”来实现的。且要在一开始就意识到未来可能会有钻石型的派生,才会想到定义一个虚基类。
复杂性都起因于虚基类,因此尽可能避免产生该死的钻石型继承。
本节主要是基于大型项目开发讲的,如果一个程序库要加入一个新的类,最轻便快捷的方法就是多继承。这实现了代码的共享与复用,却也会破坏不同类之间的关系和结构。
如果在一开始,你的脑海中就形成了类的结构,那其实怎样的继承都不会有太大问题。
2021年7月26日
C++语言结构和设计观念的映射(上章提炼)
public inheritance
和isa
之间对等- 纯虚函数:仅有函数接口被继承
- 非纯虚函数:接口及缺省代码被继承
- 非虚函数:接口和实现都被继承
- 非虚函数意味着“函数的不变性凌驾于变异性之上”
- 共同的基类意味着特性的共享
- 私有继承意味着“根据某物实现”
- layering 意味着“有一个”或“根据某物实现”
杂项讨论
清楚知道编译器为我们完成和调用了哪些函数
一个空的class何时不为空?就是在编译器对它处理完成以后。当一些函数被需要又未被定义时,编译器才会产生它们。这样产生出来的destructor并非虚拟函数,除非这个class继承自一个拥有virtual destructor的基类。
缺省取址运算符:
inline Empty* Empty::operator&() { return this; }
对于缺省复制构造函数,则赋值动作会被递归地施行于data members身上,直至找到一个copy constructor。
遇到一些内建型别,如int
char
等,它们没有内部定义的复制构造函数,就会一位一位地进行复制。
如果类中含有reference
或者 const
members,则直接修改它们是不合法的,编译器就会报错。
宁愿编译和连接时出错,也不要执行时出错
废话。
把侦测错误的工作交给编译器或者连接器去做,这样将使得程序大小降低,运行速度增加,可信度也会提高。
而执行期错误,则需要不断地测试来尽可能地涵盖。
其实,只需要稍稍改变设计,就可以在编译期捕获可能的执行期错误。比如增加一些封装好的类。
关于连接器:连接器能找到每个符号的位置并将它们连接到一起。C++使用连接器以确保用到的函数都只被定义一次,以及static objects 只被定义一次,并检测出做了声明但未被定义的函数。
使用non-local static object
之前先确定它已有初值
不要对编译器的警告信息视而不见
class B
{
public:
virtual void f() const;
};
class D:public B
{
public:
virtual void f();
};
//⚠警告信息:D::f() hides virtual B::f()
B所声明的f与D所声明的f完全不是同一个f,D::f把B::f完全遮蔽住了。要充分了解每一条警告信息,编译器是想要告诉你什么。
尽量让自己熟悉C++程序库
C++标准程序库:namespace std;
<ctring>
标准程序库
<string.h>
原有C++头文件
<cstring>
原有标准C头文件
标准程序库中的许多组件都被“template化”了。
主要组件
-
C标准函数库
-
iostreams
-
strings
-
Containers
标准程序库为 vectors、lists、queues、stacks、deques、maps、sets和bitsets、提供了高效率的实现。
-
Algorithms
即function templates,预先定义的函数。
-
国际化支持?
-
数值处理
提供复数及一些特殊数组型别以帮助数值处理。
-
诊断功能
assertions、exceptions(经由异常信息)、经由错误代码 三种记录错误的信息。
可以直接使用这些classes,也可以继承它们,产生自定义的classes。
其中,与containers和algorithms相关的那一部分被称为Standard Template Library(STL),这是标准程序库中最具革命性的部分,但我还不懂。。。
加强自己对于C++的了解
加油。
【End】
执行时出错
废话。
把侦测错误的工作交给编译器或者连接器去做,这样将使得程序大小降低,运行速度增加,可信度也会提高。
而执行期错误,则需要不断地测试来尽可能地涵盖。
其实,只需要稍稍改变设计,就可以在编译期捕获可能的执行期错误。比如增加一些封装好的类。
关于连接器:连接器能找到每个符号的位置并将它们连接到一起。C++使用连接器以确保用到的函数都只被定义一次,以及static objects 只被定义一次,并检测出做了声明但未被定义的函数。
使用non-local static object
之前先确定它已有初值
不要对编译器的警告信息视而不见
class B
{
public:
virtual void f() const;
};
class D:public B
{
public:
virtual void f();
};
//⚠警告信息:D::f() hides virtual B::f()
B所声明的f与D所声明的f完全不是同一个f,D::f把B::f完全遮蔽住了。要充分了解每一条警告信息,编译器是想要告诉你什么。
尽量让自己熟悉C++程序库
C++标准程序库:namespace std;
<ctring>
标准程序库
<string.h>
原有C++头文件
<cstring>
原有标准C头文件
标准程序库中的许多组件都被“template化”了。
主要组件
-
C标准函数库
-
iostreams
-
strings
-
Containers
标准程序库为 vectors、lists、queues、stacks、deques、maps、sets和bitsets、提供了高效率的实现。
-
Algorithms
即function templates,预先定义的函数。
-
国际化支持?
-
数值处理
提供复数及一些特殊数组型别以帮助数值处理。
-
诊断功能
assertions、exceptions(经由异常信息)、经由错误代码 三种记录错误的信息。
可以直接使用这些classes,也可以继承它们,产生自定义的classes。
其中,与containers和algorithms相关的那一部分被称为Standard Template Library(STL),这是标准程序库中最具革命性的部分,但我还不懂。。。
加强自己对于C++的了解
加油。
【End】