首页 > 编程语言 >Effective C++笔记

Effective C++笔记

时间:2023-03-28 23:12:33浏览次数:51  
标签:std const 函数 Effective 笔记 C++ 基类 new class

Effective C++ Third Edition

改善程序与设计的55个具体做法

导读

除非有理由允许构造函数被用于隐式类型转换,否则‘我’会把它声明为explicit(阻止隐式类型转换)

class tmp{
public:
    explicit tmp(int a) : numa(a){
    }
    int numa;
};

注意拷贝构造和赋值运算符的区别;拷贝构造函数比较重要,它决定这个对象如何以值(Passd-by-value)

class Persion{
    ...
};

Persion A;
Persion B = A; //调用了拷贝构造函数
A = B;  //调用了赋值运算符函数

自己习惯C++

01-视c++为一个语言联邦

C++中包含了太多特性,当处于不同次语言(sublanguage)中时,守则也会有所不同。

大致可以分为四个次语言:

  • C 传统的C语言

  • Object-Oriented C++ 面向对象

  • Template C++ 模板

  • STL 标准库

代码段在这四个语言切换时,编程守则也要跟着改变。

例如:对内置类型(C-like)而言,值传递(pass-by-value)通常比引用传递(pass-by-reference)高效。

但是,当代码从C part of C++转移到Object-Oriented C++时,由于用户会自定义构造/析构函数,所以const引用传递(pass-by-reference-to-const)会更好。在Template C++时更是如此。

然而STL中的迭代器和函数对象都是在C指针上塑造出来的,所以对这两项来说,pass-by-value更合适。

02-尽量以const,enum,inline替代define

  • 对于单纯常量,最好以const对象或enum替代#define
#define DEFAULT_SAMPLING_RATE 10000
//这个在预处理阶段,编译器会将DEFAULT_SAMPLING_RATE替换为10000,
//当需要调试或出现编译错误的的时候,只能找到10000这个数字,不利于追踪
  • 对于形似函数的宏(macros),最好使用inline函数替代
#define CALL_WITH_MAX(a,b) f((a) > (b) ? (a) : (b))
//a,b中的最大者作为实参,调用函数f

虽然这种形式,不会带来函数调用的开销,但当有人不按照常理传入参数时,会产生不可思议的问题,比如:

int a=5, b=0;
CALL_WITH_MAX(++a, b);  //a会++两次
CALL_WITH_MAX(++a, b+10);  //a只自加一次

可以使用如下方式替代:

template<typename T>
inline void callWithMax(const T& a, const T& b){
    f( a > b ? a : b);
}

03-尽可能使用const

const出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量。

如果表示被指物是常量,有些人会将const写在类型之前,有些人会把const写在类型之后、星号之前。这两种写法表示的意义相同。

void f1(const int* dat);
void f2(int const * dat);

在一个类中的const函数里,不允许修改类内的成员,但可以使用关键字mutable修饰变量,这样变量是易变的,即使在const函数中。

class TextBlock{
private:
    mutable int a;
    int b;
public:
    void test() const {
        a = 10;  //正确的
        b = 20;  //编译报错
    }
};

04-确定对象被使用前已被初始化

  • 为内置型对象进行手工初始化,C++不保证初始化它们

  • 构造函数利用好成员初始值列表,而不是在构造函数中对成员变量赋值

class Text{
public:
    Text(std::string n, int p): name(n), page(p){
        //成员的初始化顺序,取决于在类内的定义顺序,和初始化列表的顺序无关
        name2 = n;  //这是赋值操作,在此之前name2已经调用了默认构造函数
        //而初始化列表里的成员调用的是构造函数完成的初始化
    }
private:
    std::string name;
    std::string name2;
    int page;
};
  • 避免“跨编译单元之间初始化次序”的问题,以local static对象替代non-local static对象
class Directory{
    //...
};

//在首次调用这个函数时,Directory对象才会被初始化,
//可以避免声明成全局static对象时,初始化顺序的疑问
Directory& DirInstance(){
    static Directory td;
    return td;
}

构造、析构、赋值

05-了解C++默默编写并调用的那些函数

即使是空类,编译器也会生成几个默认的函数

默认构造函数、析构函数、拷贝构造函数、拷贝运算符重载函数

(好像还有其他函数:重载取指运算符、重载取指运算符const函数、移动构造函数(c++11)、重载移动赋值操作符(c++11))

  • 如果不声明构造函数,编译器会生成默认构造函数;如果已经声明了带参的构造函数,编译器不会生成默认构造了

  • 编译器默认生成的析构是no-vritual的,除非基类的析构是虚函数

  • 编译器会尝试生成‘拷贝构造函数’和‘拷贝运算符重载函数’。默认是执行每个成员的拷贝构造/拷贝运算符重载。如果有成员不符合规则(如:有const成员、reference成员),则不会生成默认的拷贝构造和拷贝运算符重载。

06-若不想使用编译器自动生成的函数,就该明确拒绝

  • 如果不想使用编译器自动生成函数,可将相应的成员函数声明为private并不予实现

  • 或者继承一个阻止赋值运算符和拷贝构造的类

即使不声明,编译器也会尝试自动生成“拷贝构造”和“拷贝运算符重载函数”,如果想明确避免这两个函数被使用,可以将其声明为private成员。但其‘友元’(friend)或成员还是可以访问,避免这个问题,可以只声明,不实现,则友元在调用时会报错(linkage error)。

class HomeSale{
public:
    int area;
    ...
private:
    HomeSale(const HomeSale&);  //只有声明
    HomeSale& operator=(const HomeSale&);
};

在类外部企图调用private成员,编译阶段会报错;友元或者内部成员调用时,只能在运行阶段报错(因为没有实现)。

将连接错误移到编译阶段,是可能的:

class Uncopyable{
protected:
    Uncopyable(){}
    ~Uncopyable(){}
private:
    Uncopyable(const Uncopyable&);
    Uncopyable& operator=(const Uncopyable&);
};

class HomeSale : private Uncopyable{
    ...
    //HomeSale不再声明拷贝构造和拷贝运算符
};

这样,当调用拷贝构造时,编译器默认生成的拷贝构造函数,会尝试调用基类的拷贝构造,就会发生报错,而不论调用者是否是内部成员或友元。

但要注意,由于Uncopeable类总扮演这一角色,可能会出现多重继承等问题。

07-为多态基类声明virtual析构函数

  • 带多态性质的基类应该声明一个virtual析构函数。如果类中带有任何的virtual函数(通常意味着被设计成可以由派生类定制实现),它就应该拥有一个virtual析构函数。

在多态中,如果基类的析构不是虚函数,而其派生类对象由基类指针被delete释放时,会产生未定义的结果。实际执行时:通常是对象的派生部分没有被销毁,派生类的析构函数也未被调用,但其基类部分会被销毁,造成一个“局部销毁”的对象。

所以如果基类被设计应用在多态场景,其析构函数需要定义为虚函数,这样才能保证delete对象时销毁整个对象。

析构函数定义为纯虚函数的话,必须提供一份定义(纯虚函数也可以有定义)。

以一个工厂函数的应用场景为例:

class TimeKeeper{
    TimeKeeper();
    ~TimeKeeper();
    ...
};
class AtomicClock: public TimeKeeper{...};
class WaterClock:  public TimeKeeper{...};
class WristWatch:  public TimeKeeper{...};

//返回一个指针,指向TimeKeeper的派生类的动态分配对象
TimeKeeper* getTimeKeeper(int mode){
    if(mode == 0) 
        return new AtomicClock();
    else
        return new WaterClock();
}

//基类指针指向派生类对象
TimeKeeper* pkt = getTimeKeeper(t);
...
delete ptk; //通过基类指针释放派生类,基类不是虚析构,会释放不全
  • 如果class的设计目的不是作为基类或者多态使用,则不该声明virtual析构函数

欲实现虚函数,类需要携带vptr(virtual table pointer)指针,vptr指向一个由函数指针构成的数组,称为vtbl(virtual table)。当调用某一虚函数时,实际被调用的函数取决于vptr所指的vtbl的函数。

如果添加了虚函数,类的体积就会增加(存放虚函数指针vptr)。在一些场景中就和拥有相同声明的C语言结构体,不再有一样的结构。

C++11有final关键字,可以指出不允许该类作为基类。

08-别让异常逃离析构函数

  • 析构函数绝对不要吐出异常。如果一个析构函数调用的函数有可能抛出异常,析构函数应该捕获这个异常,然后吞下它们(不传递)或结束程序。

  • 如果客户需要对某个函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)。

09-不在构造析构过程中调用virtual函数

  • 在构造或析构期间不要调用virtual函数,因为构造析构内的调用不下降至其派生类。
class BaseLog{
public:
    BaseLog(){
        LogCreate();
    }
    virtual ~BaseLog();
    virtual void LogCreate();
    ...
};

class ShellLog : public BaseLog{
public:
    ShellLog();
    ~ShellLog();

    virtual void LogCreate();
    ...
};

//当执行如下操作时
ShellLog flog;

定义ShellLog类时,会先调用其基类(BaseLog)的构造函数,再调用其自身的构造函数。而基类的构造中调用了虚函数LogCreate,此时派生类(ShellLog)尚不存在。虽然这行代码是定义了ShellLog类,但BaseLog构造里LogCreate执行的是自身基类版本的函数。即在基类构造期间,虚函数还不是虚函数。

如果这个虚函数还是纯虚函数,则编译器则会报错。

析构函数中调用虚函数也是类似道理:一旦派生类的析构函数开始执行,对象内的派生类成员便呈现出未定义状态,在进入基类的析构时,对象就会成为一个基类对象。

当虚函数由多次函数包含调用出现在构造析构中时,编译器可能不会报错,但执行时可能会出现问题。

10-operator=返回reference to *this

  • 令赋值操作符(拷贝运算符)重载函数返回一个当前对象的引用

  • 这个建议也适用于,operator+= , operator-= , operator*=

以应对可能出现的连续赋值

class A,B,C;
A = B = C = D;

11-operator=处理自我赋值

  • 确保当对象自我赋值时有良好行为。可做的包括:比较来源和自身地址、精心的语句顺序、及copy-and-swap.

  • 确保任何函数操作多个对象时,如果其中多个对象是同一个对象时,其行为仍然正确。

示例:

class Widget{
public:
    ...
private:
    void* pb; //指向在堆上申请的空间
};

//不安全的自我赋值
Widget& Widget::operator=(const Widget& rhs) {
    delete pb;  //释放原有空间
    pb = new Bitmap(*rhs.pb);  //申请一块空间,将rhs.pb指向的内容填入新申请的空间
    return *this;
}
//当rhs是自身时,this->pb和rhs.pb是相同的,pb已经先被释放,之后的操作使用的rhs.pb已经指向了不存在的空间

//做证同测试
Widget& Widget::operator=(const Widget& rhs) {
    if( &rhs == this ){ return *this;}
    delete pb;  //释放原有空间
    pb = new Bitmap(*rhs.pb);  //申请一块空间,将rhs.pb指向的内容填入新申请的空间
    return *this;
}

//调整代码执行顺序
Widget& Widget::operator=(const Widget& rhs) {
    void * tmpPb = pb;
    pb = new Bitmap(*rhs.pb);  //即使new时出现异常,this维护的资源也没被释放
    delete tmpPb;
    return *this;
}

//和rhs的副本进行数据交换

12-复制对象时勿忘其每一个成分

  • copying函数应该确保复制“所有成员变量”及“所有的base class成分”

  • 不要尝试用某个copying函数实现另一个copying函数。应将共同机能放进第三个函数中以供调用。

copying函数是指:‘拷贝构造’ 和 ‘拷贝运算符重载’

应该在拷贝构造中,使用初始化成员列表调用成员、基类的拷贝构造函数;在拷贝运算符重载中,使用成员、基类的拷贝运算符。

资源管理

这里的资源是指,使用之后需要归还给系统的资源。比如:动态内存、文件描述符、锁、socket、数据库连接等。

13-以对象管理资源

以工厂函数为例,当调用者使用了函数返回的对象后,就需要将其释放。
但创建对象到delete的过程不一定顺利,有可能提前return,或者抛出异常。

建议使用对象来管理资源,在对象析构时释放资源。
C++提供了智能指针,可以胜任工作。(书中提到的auto_ptr在C++11中已经不推荐使用,被unique_ptr代替)

#include <memory>
class Widget{

};

Widget* createWidget() { return new Widget;}

{
    Widget* pW = createWidget();
    ...
    delete pW;
    //在new和delete之间,可能会发生意外
}

{
    std::shared_ptr<Widget> pW( createWidget() );
    ...
    //经由shared_ptr的析构释放
}

14-在资源管理类中小心copying行为

  • 复制‘资源管理类’,必须要复制它管理的资源(深拷贝)。

  • 而开发中常见的copying行为是抑制资源复制、施行引用计数(因为有时数据的复制会增大开销,程序只需要原始数据,也并无必要复制数据)。

15-在资源管理类中提供对原始资源的访问

  • 资源管理类应该提供获取原始资源的接口,例如shared_ptr会有get接口访问原始指针。

16-成对使用new和delete时要采样相同形式

  • 如果在new中使用 [],也要在对应的delete中使用 []
  • 尽量不要对数组typedef,因为new时看不到明显的 [],但delete时需要使用 []
//正确的做法
std::string* sptr1 = new std::string("hello");
std::string* sptr2 = new std::string[10];

delete sptr1;
delete []sptr2;

//不建议typedef数组,应该使用vector等容器替代
typedef std::string Sarry[100];
std::string* saptr = new Sarry;

delete []saptr;

17-以独立语句将newed对象置入智能指针

  • 如果不是独立语句,在语句内的执行顺序无法保证,假如中间发生异常,new的对象没有来的及置入智能指针来管理,则会造成内存泄漏

设计与声明

18-让接口容易被正确使用

  • 防止参数类型一样的参数误用:将原类型封装成特有的类型、限制类型操作等

  • 减少让客户承担资源释放的责任:例如之前的工厂函数,返回new的指针,可以改成返回智能指针

  • shared_ptr可以自定义释放函数,可以防止cross-DLL-problem

19-设计class犹如设计type

  • 新type的对象应该如何被创建和销毁(构造、析构等的设计)
  • 对象的初始化和对象的赋值该有什么差别(构造函数和赋值操作符的差异)
  • 新type的对象如果passed by value(以值传递),意味着什么(copy构造的设计,和浅拷贝还是深拷贝)
  • 什么是新type的“合法值”(成员变量的有效值范围,如何进行约束)
  • 新type需要配合某个继承关系吗(注意virtual的影响,尤其是析构函数)
  • 新type需要什么样的转换(是否允许其他类型隐式、强制转换)
  • 什么样的操作符和函数对此新type是合理的
  • 什么样的标准函数应该驳回(可以声明为private,防止类外调用默认生成的函数)
  • 谁该取用新的type成员(成员可见度的设计)
  • 什么是新type的 “未声明接口” (undeclared interface)
  • 你的新type有多一般化
  • 你真的需要一个新type吗

20-传引用替代传值

  • 尽量以传引用替代传值,前者通常更高效,还可以避免切割问题

  • 内置类型并不适用上条规则

切割问题演示

class Window{
public:
    virtual ~Window() {}
    virtual void display() {
        std::cout << "display windows" << std::endl;
    }
};

class SubWindow : public Window {
public:
    virtual void display() {
        std::cout << "display sub windows" << std::endl;
    }
};

///即使传入SubWindow,传递时调用的是其基类的copy构造,
///而派生类的部分被切割了,没有copy,这里会调用Window::display,而非派生类的。
void MyDisplay(Window w) {
    w.display();
}

///传递引用,传递时是什么类型,就是什么类型
void MyDisplay2(Window& w) {
    w.display();
}

当窥视C++编译器的底层,你会发现,reference往往以指针实现出来

21-必须返回对象时,别返回引用

  • 不要返回一个指针或者引用指向局部变量,函数退出后,局部变量被销毁,指针或引用就变成未定义的
  • 也不要返回引用指向在堆上申请的空间(是为了防止用户不释放/多次释放?)
  • 如果需要同时使用多个返回值,则返回指针或引用指向局部static变量会不符合设计(类的实例函数是这么设计的,和这条提醒中的场景不一样,条款四)

22-成员变量声明为private

  • 将成员变量声明为private,需要用户访问的变量在public提供相应的函数。这样可以保证访问数据的一致性(用户不用担心是否要加括号)、可细微划分访问控制(变量的读写等),并提供class作者在修改时的弹性(只要保持接口函数不变,具体实现修改不影响用户调用)。
  • protected并不比public更具有封装性(派生类的数量也同样不可控制)。

23-以non-member non-friend函数替换member函数

  • 成员函数可以访问到private,意味着会减少封装性。

以下面例子说明:

//把一个web看作一个类,提供清除cookie、history、cache接口
class WebExample {
public:
    void clearCookie();
    void clearHistory();
    void clearCache();
};

//而有用户需要一个清除所有的接口,可有两种实现方案
//1,添加一个public函数,在函数内调用这三个函数
void WebExample::clearAll() {
    clearCookie();
    clearHistory();
    clearCache();
}

//2,设计一个非成员函数
void clearAll(WebExample& w) {
    w.clearCookie();
    w.clearHistory();
    w.clearCache();
}

在这个例子中,设计一个非成员函数更合适(非成员是指非WebExample成员,可以是别的类成员)

  • 没有破坏class的封装性
  • 有利于以后按照此方式拓展

常见的使用方式是,定义一个非成员函数,并将这个函数和class放在同一namespace下(方便识别管理,namespace可以分布在多个文件里,也方便拓展或客户选择自己需要的头文件)。

24-所有参数皆需类型转换,采用non-member函数

  • 如果你需要为某个函数的所有参数(包括被 this 指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个 non-member
    这一条在使用模板时存在争议

我看懂了这条中提供的例子,却总结不出道理,也许需要以后再来读一遍

class Rational {
public:
    Rational(int numerator=0, int denominator=1);  //没有使用explicit,编译器可以为其执行隐式转换
    int numerator() const {return numerator;}     //分子
    int denominator() const {return denominator;} //分母
    ... ...

    // 为这个类提供*运算符重载
    Rational operator*(const Rational& rhs);
};

Rational num1(2,3);

Rational num2 = num1 * 2;  //2按照num1的类型发生了隐式转换
Rational num3 = 3 * num1;  //数字3在前,编译器找不到对应的隐式转换方式,无法通过编译

// 可以提供非类成员的重载函数
Rational operator*(const Rational& rhs1, const Rational& rhs2) {
    return Rational( rhs1.numerator() * rhs2.numerator(),
        rhs1.denominator() * rhs2.denominator());
}

//这时数字在前的乘法式可以通过编译

25-考虑写出一个不抛出异常的swap函数

  • 当std::swap对自定义的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。(例如只需要互换指针的资源类)

  • 当你提供了一个成员swap函数时,应该再提供一个非成员的swap函数,在函数中调用前者。对于class(非templates),需要特化std::swap

  • 调用swap时应针对 std::swap使用 using声明式,然后调用 swap并且不带任何“命名空间资格修饰”

  • 不要在swap中抛出异常(?在条款29描述)

namespace RAT{

class Rational {
public:
    Rational(){ }

    //成员函数
    void swap(Rational& rhs) {
        std::cout << "member swap" << std::endl;
        std::swap( this->pBuff, rhs.pBuff);
    }
private:
    void *pBuff;
};

//同一命名空间下non-member版本
void swap(Rational& rhs1, Rational& rhs2) {
    std::cout << "non-member swap" << std::endl;
    rhs1.swap(rhs2);
}

}

//对这个类特例化swap
namespace std {
    template<>
    void swap<RAT::Rational>(RAT::Rational &a, RAT::Rational &b) {
        std::cout << "namespace std swap<Rational>" << std::endl;
        a.swap(b);
    }
}

int main() {
    RAT::Rational num1, num2;

    //正确的使用方式
    {
        using namespace std;
        swap(num1, num2);  
        //调用优先级:non-member版本 > std特例化版本 > std通用版本
    }

    std::cout << "--------" << std::endl;
    //不合理的使用方式
    std::swap(num1, num2);  
    //调用优先级: std特例化版本 > std通用版本,这里不会调用non-member版本
}

实现

26-尽可能延后变量定义的出现时间

  • 在使用变量前(避免程序中途退出,造成变量不必要的构造、析构)
  • 或可以使用初值构造时(避免:定义时default构造+赋值)定义变量

其他:
在循环中的变量,A是在循环外定义一次;B或在每次在循环中定义。
如果在处理效率敏感的代码段,或明确构造、析构的成本大于赋值的成本,则使用A方式;
否则应使用B方式(相对A有更好的可理解性和易维护性)

27-尽量少做转型动作

  • 如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_cast
  • 如果转型是必要的,试着将它隐藏于某个函数里面

旧式转型:
(T)Widget 或 T(Widget) 将Widget转为T类型

新式转型

const_cast<T>( expression )
dynamic_cast<T>( expression )
reinterpret_cast<T>( expression )
static_cast<T>( expression )
  • const_cast: 目标类型只能是指针或者引用,作用是去掉类型的const或volatile属性
  • dynamic_cast: 用于多态类之间的类型转换,类层次间的上行转换和下行转换
  • reinterpret_cast: 不同类型的指针类型转换(低层的转换,不同编译器上表现不同)
  • static_cast: 强迫隐式转换

28-避免返回handles指向对象内部成员

这里的handles指:指针或迭代器或 reference

  • 增加封装性
  • 最主要的是一旦通过返回值传出这种handles,就要面临所指的成员比handles更早释放的问题。

29-为‘异常安全’而努力是值得的

  • 异常安全函数(Exception-safe functions)即使发生异常也不会泄漏资源或破坏数据结构。

例如:使用智能指针保证资源可以被释放,使用unique_lock和lock_guard保证抛出异常时锁不会被一直占用。

30-透彻了解inlining的里里外外

  • 隐式inline,class内定义的函数;显式inline,加inline关键字。

  • 将大多数 inlining 限制在小型、被频繁调用的函数身上。要权衡 潜在的代码膨胀问题(重复代码会被铺开) 和 程序运行速度提升(不用函数调用的产生的资源消耗)。

  • inline关键字只是对编译器的建议,编译器可以忽略。大部分编译器拒绝将太过复杂的函数inlining,而几乎所有发virtual函数也不会inling(virtual函数意味着运行时才能确定执行哪个版本)。

  • 有时候同一和inline函数会在程序不同地方采用(inline/outline)不同的版本,比如:之间调用inline函数,编译器使用的是inline版本;利用指针调用inline函数,编译器会使用outline版本。

  • 大部分调试器面对 inline 函数都束手无策,许多环境调试版本会禁止inlining。

  • 动态库的接口不要设计成inline函数。后续修改时需要全部重新编译。

31-文件间的编译依存关系降至最低

Handle classes
没太看明白书里说的,我理解是这样:
使用一个对象时,需要知道它的定义;但只定义一个它的指针,则只用知道它的声明即可

class Data;

class Persion {
    Data *d;
    //Data d; 这是不允许的
};
... ...
/// 而在Persion的具体实现函数中,调用Data指针时,是需要知道Data的定义的

Interface classes
在头文件中设计一个接口类,里面只包含所需接口的纯虚/虚函数、创建实现类的接口,具体的实现类需要继承这个接口类。
当修改具体实现细节时,调用这个接口的程序就无需再次编译。

继承与面向对象设计

32-确定你的public继承塑模出‘is-a’关系

“public继承” 意味着 is-a ,适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也都是一个base class对象。
就是做派生类是基类的一个特殊版本的理解。

33-避免遮掩继承而来的名称

不是虚函数重写的情况下,避免派生类和基类的函数名或变量名相同。
派生类作用域内,会覆盖掉基类的同名函数,这么做不容易理解代码。

class Base{
public:
    void func1() {std::cout << "base func1" << std::endl;}
    void func1(int a) {std::cout << "base func1, a=" << a << std::endl;}
};

class Derived : public Base {
public:
    void func1() {std::cout << "derived func1" << std::endl;}

    void func2() {
        func1();  //调用的自己的func1
        //func1(1);  //基类的func1重载也被派生类的func1覆盖了,这里会无法通过编译
        Base::func1(1);  //或指定作用域
    }
};

34-区分接口继承和实现继承

  • 纯虚函数表示必须要重写的接口,虚函数则表示派生类可以自己实现的接口。

  • 如果想让派生类必须实现某个接口,同时又想提供缺省值。则可以把接口在基类定义成纯虚函数,并提供实现函数(纯虚函数也可以有实现)。

    • 这样当派生类未定义接口时,编译器会报错;当想使用默认实现时,可以在接口函数内调用基类的实现。

35-考虑virtual函数以外的方法

  • non-virtual interface(NVI)方式,在非虚函数内调用protected或private的虚函数,并作为类的接口。
    相当于对虚函数做了一层包裹,便于在调用虚函数前/后,添加这个类所需的特殊处理。(template method设计模式的一种特殊形式)

  • 以函数指针成员替换virtual函数。根据不同情况,让指针指向不同的实现函数。(strategy设计模式的一种形式)
    要注意,函数如果需要访问private成员而设置firend,可能会破坏对象的封装性。

  • 以std::function<>替换virtual函数。可以传入参数列表,更加灵活。

  • 将继承体系内的虚函数,替换成另一个继承体系的虚函数(或干脆是它整个对象)。

36-不重新定义继承来的non-virtual函数

普通函数是静态绑定,会根据指针类型来调用函数;虚函数是动态绑定,根据指针所指调用函数。

避免编程时混乱这个问题,应当不要重新定义继承来的普通函数。

37-不重定义继承来的缺省参数值

  • 如果基类的virtual函数的参数设置有默认值,派生类就不该重写这个默认值。因为virtual函数虽然是动态绑定,但参数默认值则是静态绑定。

例:

class Base {
public:
    virtual void printA(int a=10) { std::cout << "baseA, " << a << std::endl;}
};

class derived : public Base {
public:
    virtual void printA(int a=100) { std::cout << "derivedA, " << a << std::endl;}
};

int main() {
    Base* tmp = new derived;

    tmp->printA();

    delete tmp;
    return 0;
}

执行结果是:
derivedA, 10

关于静态/动态 类型/绑定:
静态类型/绑定是在编译阶段指定的,动态类型/绑定是在运行时确定的。

这个例子中tmp的静态类型是Base* ,动态类型是derived。virtual函数是动态绑定,但参数默认值是静态绑定。
所以tmp->printA函数会跟着tmp的动态类型不同而绑定不同的函数;而参数默认值一直是静态类型对应的。

38-通过复合塑模出 has-a

之前(条款32)提出的is-a的概念是继承发生时,派生类意味着是一个(is-a)特殊的基类。

这里的has-a是指在编程中,会有很多对象并不需要/适合做继承,这时就需要把其他对象定义成自己的成员。

39-明智而审慎的使用private继承

  • private继承意味着has-a的模型,这种情况可以在类中声明对应的成员对象替代(条款38)。
    • 但当派生类需要访问其protected成员或重写virtual函数,但又不想在自身开放这个对象的接口,private继承是合理的。

40-明智而审慎的使用多重继承

多重继承容易出现菱形继承的情况,使用虚继承可以保证菱形上的基类只有一份。

  • 多重继承比单一继承复杂。它可能导致新的歧义性,以及对 virtual 继承的需要。
  • virtual继承会增加大小、速度、初始化(及赋值)复杂度等等成本。如果 virtual base classes 不带任何数据,将是最具实用价值的情况。
  • 多重继承的确有正当用途。其中一个情节涉及“public继承某个Interface class” 和 “private 继承某个协助实现的 class”的两相组合。

模板与泛型编程

41-了解隐式接口和编译期多态

class和template都支持接口和多态

  • class的接口是显示的;通过继承和virtual函数实现多态,发生在运行阶段
  • template的接口是隐式的;通过template的具象话来实现多态,发生在编译阶段

关于template的接口是隐式的理解:

template<typename T>
void test_func(T &t) {
    if (t.size() > 10) {
        t.resize();
    }
}
//这里模板T隐式的有size()和resize()接口,如果传入的类型没有这两个接口,则编译报错。
//而对T和其成员的类型要求并不严格,如果可以通过隐式转换成需要的类型,就可以通过编译。

42-了解typename的双重意义

  • 声明模板参数时,class和typename可以互换,意义相同
  • typename用来标识“嵌套从属类型名称”,但在基类列表、成员初始值列表不使用
template<typename T>  //这里typename和class关键字可以互换
class Derived : public Base<T>::NEsted {   //基类列表不使用typename
    Derived(int x) : Base<T>::NEsted(x) {  //成员初始值列表不使用
        typename Base<T>::NEsted tmp(x);   //嵌套从属类型,需要加typename关键字,否则编译器不认为这是个类型
        typename Base<T>::NEsted::iter it; //涉及到模板内部的类型,使用时需要加typename
    }
}

43-学习处理模板化基类内的名称

  • 模板化基类的成员在派生类无法直接访问,可以通过this指针或指定作用域访问。
  • 模板化会让继承不再畅行无阻
template<typename T>
class Base {
public:
    virtual void printA(T a) { std::cout << "baseA, " << a << std::endl;}
    void printB(T b) {std::cout << "baseB, " << b << std::endl;}
};

template<typename T>
class Derived : public Base<T> {
public:
    virtual void printA(T a) { std::cout << "derivedA, " << a << std::endl;}

    void derived_print(T c) {
        //printB(c);  //编译报错,基类带模板参数时,编译器不去未具象化的基类查找函数定义
        this->printB(c);  //可以通过this指出
        Base<T>::printB(c); //或指定作用域
    }
};

int main() {
    Base<std::string>* d1 = new Derived<std::string>;
    d1->printA("qwer");
    d1->printB("123");
    //d1->derived_print("qw");  //这里竟然undefined
    delete d1;

    Derived<std::string> d2;
    d2.printA("fesd");
    d2.printB("123");
    d2.derived_print("zxc");

    return 0;
}

44-将与参数无关的代码抽离template

  • 非类型模板参数可能会导致模板生成的代码膨胀。

例如:

template<typename T, size_t n>
class Widge {
... ...
};

Widge<std::string, 10> w1;
Widge<std::string, 100> w2;

这样w1和w2成了两个类型,但绝大部分处理中10和100所属的size_t类型处理方式是一致的。
而size_t和模板类型无关,是属于非类型模板参数。

解决方案有:模板类只保留模板参数,在派生类传入不同的size_t。

45-运用成员函数模板接受所有兼容类型

  • 使用成员函数模板接受所有兼容类型。我理解为可以不加explicit关键字,让兼容类型发生隐式转换;或者明确声明所有兼容参数的函数。
  • 看的我实在是头疼。

46-需要类型转换时请为模板定义非成员函数

条款24介绍了需要/允许参数隐式转换的函数要定义成类的非成员函数。
对于模板也是这样,但条款24的形式不适用模板。

我这里进行了测试,模板类Rational不再对int进行隐式转换,无论重写乘法的函数定义在类内或类外。
但函数定义在类内或类外都可以通过编译。

template<typename T>
class Rational {
public:
    Rational(T num, T den) : numerator_(num), denominator_(den) {

    }
    T numerator() const {return numerator_;}     //分子
    T denominator() const {return denominator_;} //分母

    Rational<T> operator*(const Rational<T>& rhs) {
        std::cout << "member func" << std::endl;
        return Rational<T>( this->numerator() * rhs.numerator(),
            this->denominator() * rhs.denominator());
    }

private:
    T numerator_;
    T denominator_;
};

//定义成类外函数
template<typename T>
Rational<T> operator*(const Rational<T>& rhs1, const Rational<T>& rhs2) {
    std::cout << "non-member func" << std::endl;
    return Rational<T>( rhs1.numerator() * rhs2.numerator(),
        rhs1.denominator() * rhs2.denominator());
}

int main() {
    Rational<int> num1(2,3);

    Rational<int> num2 = num1 * Rational<int>(2,0);  //2按照num1的类型发生了隐式转换
    // Rational<int> num2 = num1 * 2;  //不会隐式转换,编译报错
    // Rational<int> num3 = 3 * num1;

    std::cout << num2.numerator() << std::endl;
}

47-请使用 Traits classes 表现类型信息

  • Traits classes 使得“类型相关信息”在编译期可用,它们以 templates 和 “templates特化” 完成实现。
  • 使用重载,traits classes 有可能在编译期对类型执行if...else 测试。

这一节理解的不清楚,并且也是我第一次听说这个概念。

Traits classes 为使用者提供类型信息。例如标准库中的容器模板都有一个value_type,指示容器元素的类型。

48-认识template元编程

TMP的思想是把一些工作从运行期移至编译期,获得编译时的侦错、提高运行期的效率。

//计算阶乘的模板

template<unsigned T>
struct Factorial {
    enum { value = T * Factorial<T-1>::value };
};

//特例化模板
template<>
struct Factorial<0> {
    enum { value = 1 };
};

int main() {
    std::cout << Factorial<10>::value << std::endl;
    std::cout << Factorial<5>::value << std::endl;
    return 0;
}
//TMP 系以“递归模板具现化”(recursive template instantiation) 取代循环,每个具现体有自己的一份 value, 而每个value有其循环内的适当值。

定制new和delete

49-了解new-handler的行为

  • std::set_new_handler(new_handler)函数可以指定一个函数,在new失败时调用。
    • new-handler函数应该抛出bad_alloc异常、不返回并调用abort或exit
//节选自c++/{version}/new
  /** If you write your own error handler to be called by @c new, it must
   *  be of this type.  */
  typedef void (*new_handler)();

  /// Takes a replacement handler as the argument, returns the
  /// previous handler.
  new_handler set_new_handler(new_handler) throw();

50-了解new和delete的合理替换时机

new和delete可以重载成自定义的函数

void* operator new(std::size_t size) {
    std::cout << "hello new: size= " << size << std::endl;
    void* p = malloc(size);
    return p;
}

这么做的主要目的有:

  • 为了检测运行错误。
  • 为了对内存的使用进行统计。
  • 增加分配和归还的速度。
  • 为了降低缺省内存管理器带来的空间额外开销。
  • 为了弥补缺省分配器中的非最佳齐位(suboptimal alignment)。
  • 为了将相关对象成簇集中。
  • 为了获得非传统的行为。

51-编写new和delete时需固守常规

  • 即使用户申请size_t为0的空间,也需要正确返回。重载new时需要做特殊处理。

  • delete空指针需要是安全的。同上,重载delete时需要对空指针做判断。

  • operator new 应该内含一个无穷循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用 new-handler, 它也应该有能力处理 0 bytes 申请。Class专属版本则还应该处理“比正确大小更大的(错误)申请”。

  • operator delete 应该在收到 null 指针时不做任何事。Class 专属版本则还应该处理“比正确大小更大的(错误)申请”。

52-写了 placement new 也要写 placement delete

  • 如果一个带额外参数的 operator new没有“带相同额外参数”的对应版 operator delete, 那么当new的内存分配动作需要取消并恢复旧观时就没有任何 operator delete 会被调用。——造成内存泄漏。

    • 当new失败时,c++需要负责释放掉已经申请的内存。
  • 重载new和delete时,不要在不需要的地方掩盖默认的new和delete函数。

杂项讨论

53-不要轻易忽略编译器的警告

  • 严肃对待编译器警告,尽量减少警告。

    • 个人经验:函数没有返回值的警告!尤其需要重视,可能会导致程序崩溃。可以使用编译选项:-Werror=return-type,将没有返回值的waring指定为error。
  • 不要过度依赖编译器警告,在不同平台上警告的表现可能会不同。

54-让自己熟悉标准库

55-让自己熟悉boost

boost.org

done

感悟:
这本书从去年年底就开始看,中间断断续续,二三月份才开始认真阅读。有些章节读着确实是痛苦不堪,自己偶尔也会心浮气躁。
但总体还是收获不少,记下这些笔记,希望以后没印象的时候可以再回头翻看,温故知新。

标签:std,const,函数,Effective,笔记,C++,基类,new,class
From: https://www.cnblogs.com/TaXueWuYun/p/17267142.html

相关文章

  • 人月神话读书笔记
    第1章:焦油坑大型系统开发就像一个焦油坑,很多强壮的动物都在其中挣扎。如果将一个“程序”提升为“产品”(意味着:通用化、测试、文档、维护)需要3倍的时间;如果将一个“程......
  • 人月神话读书笔记2
        在刚刚进入软件工程学习时,老师总会时不时向我们提起一些关于“软件项目开发的完成与增加人员的问题”这句话听起来通俗易懂,但实现起来却遇到了相当大的困难,这是......
  • 软考高 信息系统项目管理笔记1
    十大管理:人:进度管理成本管理范围管理事:沟通管理资源管理干系人管理人/事:质量管理采购管理风险管理五大过程:启动、计划、执行、监督、收尾#PDCA:......
  • PMP考点笔记
    引论项目项目的特点级作用:独特行、临时性项目的启动背景:满足倒金字塔型条件项目相关方管理项目预算三大指标在指定时间点:计划价值PV:计划要完成的工作量挣值......
  • L2-001-紧急救援*C++(使用Dijkstra算法附带全详细注释)
     L2-001紧急救援分数 25 作为一个城市的应急救援队伍的负责人,你有一张特殊的全国地图。在地图上显示有多个分散的城市和一些连接城市的快速道路。每......
  • C++ 树进阶系列之笛卡尔树的两面性
    1.前言笛卡尔树是一种特殊的二叉树数据结构,融合了二叉堆和二叉搜索树两大特性。笛卡尔树可以把数列(组)对象映射成二叉树,便于使用笛卡尔树结构的逻辑求解数列的区间最值或......
  • 构建之法阅读笔记1
    《构建之法》第一章介绍了软件工程的概念、理论、知识点和软件工程和计算机科学的关系。具体来说是让我认识到了以下几个概念:源代码管理,配置管理,质量保证,软件测试,需求分析......
  • 动力节点王鹤SpringBoot3学习笔记——JDK新特性
    一、JDK关注的新特性1.1搭建学习环境JDK:JDK19OpenJDK:https://jdk.java.net/19/LibericaJDK:https://bell-sw.com/pages/downloads/,是一个OpenJDK发行版,为云原生,......
  • 构建之法阅读笔记03
    第十一章软件设计与实现的学习;分析和设计方法典型的开发流程,常见的分析和设计方法:ERD,DFD,UML,开发阶段的一些管理方法:每日构建,小强地狱,构建大师;分析和设计方法包括以文字......
  • MySQL学习笔记-存储引擎
    存储引擎一.MySQL体系结构MySQLServer连接层:连接的处理、认证授权、安全方案、检查是否超过最大连接数等。服务层:SQL接口、解析器、查询优化器、缓存引擎层:引擎......