C++ 进阶笔记

前言

从一个iOS程序员转到 C++ 图形引擎程序员已经快一年了,刚开始总是在网上看一些零零碎碎的C++知识,没有系统性的学习C++ 知识。有一次下班时间,在公司坐着与同事闲谈,聊聊一些技术,在聊天过程中发现自己写的很多 C++ 代码是有问题的,于是后面就找同事借了一些C++ 相关的书来看,先是看《C++ Primer》这本,这本书在业内得到很多人的推荐,听有人说吃透这本书的话,薪资起码 30K/月,于是我也啃了啃这本书,大概浏览了一遍所有内容,差不多花了两个月,但是很多东西都没记下来,给忘了,后来自己买了本《Effective C++》来看,书中的内容并不是所有平常工作中都能用得到,这里我提取《Effective C++》中自认为比较常用和重要的几个具体做法给做一个记录。

目录

  • 尽量以 const、enum、inline 替换 #define
  • 尽可能使用 const
  • 确定对象被使用前已被初始化
  • 为多态基类声明 virtual 析构函数
  • 绝不在构造和析构过程中调用 virtual 函数
  • 令 operator= 返回一个reference to *this
  • 宁以 pass-by-reference-to-const 替换 pass-by-value
  • 必须返回对象时,别妄想返回其 reference
  • 将变量声明为 private
  • 避免返回 handles 指向对象内部成分
  • 绝不重新定义继承而来的 non-virtual 函数
  • 绝不重新定义继承而来的缺省参数值

尽量以 const、enum、inline 替换 #define

请记住

  • 对于单纯常量,最好以const 对象替换 #defines。
  • 对于形似函数的宏(macros),最好改用inline 函数替换#defines。

我们无法利用 #define 创建一个class 专属常量,因为作用域对 #define 无效,一旦被宏定义,它就在其后的编译过程中有效。这意味着#defines 不仅不能够用来定义class 专属常量,也不能够提供封装性。

使用常量替换#defines,有两种特殊情况得说一说。

  1. 是定义常量指针。由于常量定义式通常被放在头文件内(以方便不同的源码引入包含),因此有比较将指针声明为const。例如若要在头文件内定义一个常量 子图传,你必须些const 两次:
const char * const authorName = "Scott Meyers"

这里使用 string来替代前辈 char * 更合适,所以上述的authorName 往往定义成这样更好些:

const std::string authorName = "Scott Meyers";
  1. Class 专属常量。为了将常量的作用域限制于class 内,你必须让它成为class 的一个成员;而为确保此常量至多只有一份实体,你必须让它成为一个 static 成员:
class ConstDemo {
private:
  static const int NumTurns = 5;// 常量声明
  int scores[NumTurns];// 使用该常量
};

通常C++ 要求你对你所使用的任何东西提供一个定义式,但如果它是个class 专属常量又是static 且为整数类型,则需要特殊处理。只要不取它们的地址,你可以声明并使用他们呢而无需提供定义式。但如果你取某个class 专属常量的地址,或纵使你不取其地址而你的编译器却坚持要看到一个定义式,你就必须另外提供定义式如下:

const int GakePlayer::NumTurns;

把上面这个式子放进实现文件里面,因为在头文件里面已经给 NumTurns设置了初始值,因此定义时不可以再设初始值。

另一个常见的#define误用情况是以它实现宏。宏看起来像函数,但不会招致函数调用(function call)带来的额外开销。下面这个宏夹带着宏实参,调用函数f:

#define CALL_WITH_MAX(a, b) f((a)>(b)?(a):(b))

无论何时当你写出这种宏,你必须记住宏中的所有实参加上小括号,否则某些人在表达式中调用这个宏时可能会遭遇不可估计的麻烦。纵使你为所有实参加上小括号,下面不可思议的问题还会发生:

  int a =5, b =0;
  CALL_WITH_MAX(++a, b);// a 被+了两次
  CALL_WITH_MAX(++a, b+10);// a被+了一次

为了解决这种问题,如果把上面的宏改成 template inline 函数,你就可以获得宏带来的效率以及一般函数的所有可预料行为和类型安全性:

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

这个templete 产出移整群函数,每个函数都接受两个同类型对象,并以其中较大者调用f。这里不需要在函数体中为参数加上括号,也不需要操心参数被核算多次,等等。此外callWithMax是个真正的函数,它遵守作用域和访问规则。例如可以写出一个class 内的private inline 函数。一般而言使用宏是无法完成此事。

有了 consts和inline,我们对预处理器的需求降低,但并非完全消除。#include 仍然是必需品,而 #ifdef/#ifndef 也继续扮演控制编译的角色。目前还不到预处理器全面引退的时候。

尽可能使用 const

请记住

  • 将某些东西声明为 const 可帮助编译器侦测出错误用法。const 可被施加于任何作用域的对象、函数、参数、函数返回类型、成员函数本体。
  • 当const 和non-const 成员函数有着实质等价的实现时,令non-const版本调用const 版本可避免代码重复
    可以用来在classes外部修饰全局或命名空间作用域中的常量、文件、函数、区块作用域中被声明static 的对象。你也可以用它修饰classes 内部的static 和non-static 成员变量。可以用来修饰指针自身、指针所指物,或者两者都是 const:
  char greeting[] = "Hello";
  char *p = greeting;// non-const pointer, non-const data
  const char *p1 = greeting;// non-const pointer, const data
  char *const p2 = greeting;// const pointer, non-const data
  const char *const p3 = greeting;// const pointer, const data

const 语法虽然变化多端,但并不高深莫测。如果关键字const 出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量;如果出现在星号两边,表示被指物和指针两者都是常量。

STL 迭代器是以指针为根据数模出来,所以迭代器的作用就像个 T * 指针。声明迭代器为const 就像声明指针为 const 一样(即声明一个 T *const 指针),表示这个迭代器不得指向不同的东西,但它所致的东西的值是可以改动的,如果你希望迭代器所指的东西不可被改动, 需要使用

const_iterator:
  std::vector<int> vec;
  const std::vector<int>::iterator iter =vec.begin(); // iter 的作用像个 T*const
  *iter = 10;// 没问题,改变 const 所指物
   
  ++iter;// 错误,iter 是 const
   
  std::vector<int>::const_iterator cIter = vec.begin();
  *cIter = 10;// 错误, *cIter 是const
  ++cIter;// 没问题,改变 cIter

const 成员函数

将const 作用于成员函数的目的,是为了确认该成员函数可作用于const 对象身上。这一类成员函数之所以重要,基于两个理由。

  1. 它们使class接口比较容易被理解。这是因为,得知哪个函数可以改动对象内容而哪个函数不行;
  2. 它们使“操作const对象”成为可能。

两个成员函数如果只是常量性不停,可以被重载。这实在是一个重要的C++特性。考虑下class,用来表现一块文字:

class TextBlook {
public:
  TextBlook(std::string _text) {
    text = _text;
  }
  const char & operator[](std::size_t position) const {
    return text[position];
  }
   
  char & operator[](std::size_t position) {
    return text[position];
  }
private:
  std::string text;
};

TextBlock 的operator 可被这么使用:

  TextBlook tb("Hello");
  std::cout<<tb[0];// 调用 TextBlook::operator[]
  tb[0] = 'x';// 正确
  const TextBlook ctb("World");
  std::cout<<ctb[0];// 调用 const TextBlook::operator[]
  ctb[0] = 'x';// 错误 写一个 const TextBlock

需要注意的是,上述错误只是因为 operator[] 返回const 类型导致,至于operator[] 调用动作自身没问题,错误起因于企图对一个“由const 版的 operator[] 返回” 的const char &进行复制动作。

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

请记住

  • 为内置类型对象进行手动初始化,因为C++不保证初始化他们。
  • 构造函数最好使用成员初值列表,而不要在构造函数本体内使用赋值操作。初值列列出的成员变量,其排列次序应该和他们在class中声明次序相同。
  • 为了避免“跨编译单元的初始化次序”问题,请以 局部 static 对象 替换 局部非static对象。

我们先来看下面这段代码:

lass Point {
  int x, y; 
public:
   
};
Point p;

p 的成员变量有时候被初始化为0.有时候不会。读取为初始化的值会导致不明确的行为,在某些平台上,仅仅只是读取为初始化的值,就可能让你的程序终止运行。

为了避免上面的问题出现,最佳的处理办法是:永远在使用对象之前先将它初始化。对于任何成员的内置类型,必须手动初始化,例如:

  int x = 0;// 手动进行初始化
  const char *text = "A c-style string";//对指针进行手动初始化
  double d;//
  std::cin>>d;// 以读取input stream 的方式完成初始化

对于内置内省以外的任何其他东西,初始化责任落在构造函数身上。规则很简单:确保每一个构造函数都将对象的每一个成员初始化。
举个例子:

class PhoneNumber {
public:
};
class ABEntry {
  std::string theName;
  std::string theAddress;
  std::list<PhoneNumber> thePhones;
  int numTimesConsulted;
   
public:
  ABEntry(const std::string &name, const std::string &address, const std::list<PhoneNumber> &phones);
};

ABEntry::ABEntry(const std::string &name, const std::string &address, const std::list<PhoneNumber> &phones):theName(name), theAddress(address), thePhones(phones),numTimesConsulted(0){}

由于编译器会为自定义类型的成员变量自动调用default构造函数--如果那些成员变量在“成员初始值列”中没有被指定初始值的恶化,因而引发某些程序员过度夸张地采用以上写法。那是可以理解的,但请立下一个规则,规定纵使在初始值列中列出所有成员变量,以免还记得住那些成员变量可以无需初始值,举个例子,由于 numTimesConsulted 属于内置内省,如果成员列遗漏了它,它就没有初始值,因而可能就开启了“不明确行为”的潘多拉盒子。
C++ 有着十分固定的“成员初始化次序”。是的,次序纵使相同:基类早于派生类,而class的成员变量总是以其声明的顺序被初始化。回头看看 ABEntry ,其theName 成员永远最先被初始化,然后是 theAddress,在是 thePhones,最后是 numTimesConsulted。即使他们在成员在初始列中以不同的次序出现,也不会有任何影响。为避免你活腻的检阅这迷惑,并避免某些可能存在的晦涩错误,当你在成员初始值列中条列各个成员时,最好总是以其声明次序为次序。

不同编译单元内定义的 非局部静态对象

我们先来看一段代码

// 来自程序库1
class FileSystem {
  //。。。
public:
  //。。。
  int numDisks() const;
};
extern FileSystem tfs;// 准备给其他对象使用的对象,tfs 代表 “the file system”

// 来自程序库2
class Directory {
   
public:
  Directory() {
    int disks = tfs.numDisks();
    // 。。。
  }
};

进一步假设我们客户端创建一个 全局的临时 Directory 对象:

Directory tempDir();

这个代码大致看上去应该没什么问题,实际上问题很大,问题出在哪儿,我们来分析一下:除非tfs在tempDir 之前被初始化,否则tempDir 的构造函数会用到尚未初始化的tfs。但 tfs和tempDir是不同的人在不同的时间,位于不同的源码文件建立起来的,他们定义于不同的编译单元内的局“局部非静态对象”。如何能确定tfs会在tempDir之前先被初始化呢?
答案是无法确定,C++ 对 定义于不同编译单元内的 局部非静态对象 的初始化相对次序并无明确定义。这是有原因的:决定他们的初始化次序相当困难,非常复杂,根本无解。
幸运的是一个小小的设计便可完全消除这个问题。唯一需要做的是:将每个 局部非静态对象搬到自己的专属函数内,这些函数返回一个 referece 指向它所含的对象。然后用户调用这些函数,而不直接取这些对象。换句话说,将 局部非静态对象 转换为局部静态对象。
这个做法的基础在于:C++ 保证,函数内的local static 对象会在 “该函数被调用期间”“首次遇上盖对象之定义式”时被初始化。所以如果你以“函数调用返回一个 局部 static 对象”替换“直接访问 局部非静态对象”,你就获得了保证,保证你所获得的那个reference将只想一个历经初始化的对象。改造之后的代码如下:

// 来自程序库1
class FileSystem {
  //。。。
public:
  //。。。
  int numDisks() const;
};

FileSystem & tfs() {
  static FileSystem fs;
  return fs;
}

// 来自程序库2
class Directory {
   
public:
  Directory() {
    int disks = tfs().numDisks();
  }
};

Directory &tempDir() {
  static Directory td;
  return td;
}

这么修改之后,客户端的用法跟以前基本一样,唯一不同的是,他们现在使用 tfs() 和 tempDir()。也就是说他们使用函数返回的“指向 static 对象”的引用,而不是 static对象自身。

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

请记住

  • 带多态性质的基类应该声明一个virtual 析构函数。如果class 带有任何virtual 函数,它就应该拥有一个virtual 析构函数。
  • Class 的设计目的如果作为 基类使用,或不具备多态性质,就不该声明virtual析构函数。

我们来假设一种场景。有许多种做法可以记录时间,因此,设计一个TimeKeeper 基类 和一个些派生类作为不同的计时方法,相当合情合理,代码如下:

class TimeKeeper {
   
public:
  TimeKeeper();
  ~TimeKeeper();
};


class AtomicClock: public TimeKeeper {};// 原子钟
class WaterClock: public TimeKeeper {};// 水钟
class WristClock: public TimeKeeper {};// 腕表

许多客户只想在程序中使用时间,不想操心时间如何计算等细节,这时候我们可以设计factory函数,返回一个计时对象。factory 函数会“返回一个基类指针,指像新生成的派生类对象”:
TimeKeeper *getTimeKeeper();

为了遵守factory 函数的规矩,被 getTimeKeeper() 返回的对象必须位于队内存中,因此为了避免内存泄漏,将factory 函数返回的每一个对象适当地时候delete 掉很重要:

TimeKeeper *ptk = getTimeKeeper();
//。。。
delete ptk;

上面这段代码问题出在 getTimeKeeper 返回指向一个 派生类对象,而那个对象却由一个 基类指针被三处,而目前基类有一个 non-virtual 析构函数。
这是一个引来灾难的秘诀,因为 C++明白指出,当派生类对象经由一个基类指针被删除,而该积累带着一个non-virtual 析构函数,其结果未有定义--实际执行时通常发烧的是对象的派生部分没有被销毁。如果 getTimeKeeper 返回指针指向一个 AtomicClock对象,其内部的 AtomicClock 部分很可能没被销毁,而 AtomicClock 的析构函数也未执行起来。然而其 基类部分通常会被销毁,于是诡异“局部销毁”对象。这可是形成资源泄漏、败坏数据结构、在调试器上浪费许多时间的绝佳途径喔。

消除这个问题的做法很简单:给 基类一个 virtual 析构函数。此后删除派生类对象就会如你想要的那般销毁整个对象,包括了派生类的成分:

class TimeKeeper {
public:
  TimeKeeper();
  virtual ~TimeKeeper();
};

在C++ 编程中,如果 class 不含virual函数,通常表示它并不意图被用作一个基类。当类不企图当做基类,令其析构函数为 virtual 往往是个馊主意。
这里举个例子:

class Point {
public:
  Point(int _x, int _y):x(_x), y(_y){};
private:
  int x, y;
};

如果 int 占用 32 bits,那么 Point 对象可塞入64-bit 缓存器中。更有甚者,这样一个point 对象可被当做一个 “64-bit量”传递给其他语言,如C语言撰写的幻术,然而当point 的析构函数时virtual函数,形势起了变化。
欲实现出virtual 函数, 对象必须携带某些信息,主要用来在运行期决定哪一个virtual 函数该被调用。这份信息时由一个所谓vptr(virtual table pointer)指针指出。vptr 指向一个由函数指针构成的数组,称为 vtbl(虚函数表);每一个带有 virtual 函数的class 都有一个相应的vtbl。当对象调用某一个virtual函数,实际调用的函数取决于该对象的vptr所指的那个vtbl--编译器在其中寻找适当的函数指针。

Virtual 函数的实现细节不重要。重要的是如果point class 内含 virtual 函数,其对象体积会增加:在32-bit计算机体系结构中将占用64bits(存放两个ints)至96bits(两个ints 加上 vptr);在 64计算机体系结构中可能占用64~128bits,因为指针在这样的计算机结构中占用64bits。因此,point 添加一个vptr会增加其大小达50%~100%!Point 对象不在能够塞入一个64-bit缓存器,而C++ 的point对象也不在和其他语言内的相同声明有着一样的结构,因此也就不在可能把它传递至其他语言所写的函数,除非你明确补偿vptr--那属于实现细节,也因此不再具有可移植性。
因此无端将所有classes 的析构函数声明为 virtual,就像从未声明他们为virtual一样,都是错误的。

绝不在构造和析构过程中调用 virtual 函数

请记住

  • 在构造和析构期间不要调用虚函数,因为这类调用从不下降之 派生类

重点:你不应该在构造函数和析构函数期间调用virtual函数,因为这样的调用不会带来你预想的结果,就算有你也不会高兴。

假设你有个class 继承体系,用来塑膜股市交易买进和卖出的订单等等,这样的交易一定要经过审计,所以当创建一个交易对象,在审计日志中需要创建一笔记录。代码如下:

class Transaction {
public:
  Transaction();
  virtual void logTransation() const;
};

void Transaction::logTransation() const {
  std::cout<<"Transaction";
}

Transaction::Transaction() {
  logTransation();
}

class BuyTransaction:public Transaction {
   
public:
  virtual void logTransaction() const {
    std::cout<<"BuyTransation";
  }
};

class SellTransaction:public Transaction {
   
public:
  virtual void logTransaction() const {
    std::cout<<"SellTransation";
  }
};

现在。当一下的这行被执行,会发生什么事:

BuyTransation b;

无疑会有一个 BuyTransation 构造函数被调用,但首先Transaction 构造函数一定会更早被调用,派生类 对象内的基类 成分会在派生内自身成分构造之前先构造完成。Transaction 构造函数最后一行调用了 虚函数 logTransation ,正是引发问题的起点。这时候被调用的 logTransation 是Transaction内的版本,而不是 BuyTransation 内的版本,即使我们要构建的是一个BuyTransation。在 基类的构造期间,虚函数不会下降到派生类。取而代之的是,对象的作为就像隶属基类一样。

从另一个角度思考,由于基类在构造函数的执行早于派生类构造函数,当基类构造函数执行时派生类的成员尚未初始化。如果此期间调用的 虚函数下降至 派生类这一层,要知道派生类函数几乎必然取用其成员变量,而这些成员变量上为初始化,使用尚未初始化的变量是很危险的。C++ 为了避免这种危险的现象出现,所以C++ 不准这么做。

其实还有比上述理由更根本的原因:派生类对象的基类构造函数期间,对象的类型是基类而不是派生类。不只是虚函数会被编译器解析至基类,若使用运行期类型信息(例如dinamic_cast和typeid)也会把对象视为基类类型。

相同的道理也适用于析构函数函数。一旦派生类析构函数开始执行,对象的派生类的成员变量就会被释放,所以C++ 视他们仿佛不再存在。进入基类析构函数函数后对象就成为一个基类对象,而C++ 的任何部分包括virtual 函数、dynamic_cast 等等也就这么看待它

上述例子中,在基类构造函数中直接调用了虚函数,这很明显而且很容易看出来违反本条款,某些编译器会为此发出一个警告信息。即使没有这样的警告信息,肉眼也很容易看得出来。其实在检查“构造函数或析构函数中是否调用virtual 函数”并不是总是这样轻松简单的。如果 上述例子中的Transaction 存在多个构造函数,,每个构造函数都需要执行相同的一些工作,那么避免代码重复,就会把相同的代码单独抽到一个非虚函数里面,如下代码:

class Transaction {
public:
  Transaction();
  virtual void logTransation() const;
  void init() {
    logTransation();
  }
};

void Transaction::logTransation() const {
  std::cout<<"Transaction";
}

Transaction::Transaction() {
  init()
}

这个版本的代码与之前版本的代码差不多,不同的是问题藏得比较深,暗中危害,因为它通常不会引发编译器和连接器的抱怨。此时由于 logTransation 是 Transaction 的一个虚函数,当虚函数被调用,就会调用 Transaction 内的实现代码,留给你百思不得解为什么建立一个派生对象时会调用错误版本的 logTransation。唯一能够避免此问题的做法是:确定你的构造函数和析构函数都没有调用虚函数,而它们调用的所有函数也都服从同一约束。

但如何确保每次一个Transaction 继承体系上的对象被创建,就会有适当版本的 logTransation 被调用呢?
首先排除在 Transaction构造函数内对着对象调用虚函数,因为这种做法是错误的。
其他方案可以解决这种问题。一种做法是在class Transaction 内将logTransation 改成非虚函数,然后要求派生类构造函数传递必要信息给 Transaction 构造函数,而后那个构造函数便可安全地调用 非虚函数 logTransation。就像这样:

class Transaction {

public:
    Transaction(const std::string &logInfo);
    void logTransation(const std::string &logInfo) const;
    
};

void Transaction::logTransation(const std::string &logInfo) const {
    std::cout<<logInfo;
}

Transaction::Transaction(const std::string &logInfo) {
   
        logTransation(logInfo);
 }

class BuyTransation:public Transaction {
  static std::string createLogString() {
    return "BuyTransation_Log";
  }
public:
  BuyTransation():Transaction(createLogString()) {}
};


class SellTransation:public Transaction {
  static std::string createLogString() {
    return "SellTransation_Log";
  }
public:
  SellTransation():Transaction(createLogString()) {}
};

换句话说由于你无法使用虚函数从基类向下调用,在构造期间,“我们可以使派生类将必要的构造信息上传至 base class 构造函数”替换之并加以弥补之前的缺陷

令 operator= 返回一个 *this 引用

请记住

  • 令赋值操作符返回一个 *this 引用。

关于复制,有趣的是你可以把他们写成连锁形式:

int x, y, z;
x = y = z = 15;// 赋值连锁形式

同样有趣的是,赋值采用右结合律,所以上述连锁赋值被解析为:

x = (y = (z = 15));

这里15 先被赋值给 z ,然后其结果再被赋值给 y,然后其结果再被赋值给 x。
为了视线“连锁复制”,复制操作必须返回一个“引用”指向操作符左侧实参。这是你为class实现赋值操作符时应该遵循的协议:

class Widget {
public:
  Widget& operator=(const Widget& rhs) {// 返回类型是个 “引用”,指向当前对象
    //...其他操作
    return *this;// 返回左侧对象
  }   
};

这个协议不仅适用于标准赋值形式,也适用于所有肤质相关运算,如 +=、-=、*=、/=.

使用 “不可变引用传递” 替换 “值传递”

“值传递”的性能分析请记住

  • 尽量以 “不可变引用传递” 替换 “值传递”,前者通常比较高效避免切割问题。
  • 以上规则并不适用于内置类型,以及STL的迭代器和函数对象。对于他们而言,值传递 往往比较妥当。
    缺省情况下C++ 以“值”方式传递对象至函数。除非另外制定,否则函数参数都是以实际参数的副本为初值,而调用端获得亦是函数返回值的一个副本。这些副本系由对象的copy构造函数产出,这可能使得“值传递”成为非常昂贵的操作。

“值传递”的性能分析

看一下下面这段代码:

class Person {
  std::string name;
  std::string address;
public:
  Person();
  virtual ~Person() {}

};

class Student:public Person {
  std::string schoolName;
  std::string schoolAddress;
public:
  Student();
  ~Student(){}
};

现在考虑一下代码,其中调用函数 validateStudent,后者需要一个 Student 实参并返回是否有效

bool validateStudent(Student s);

Student plato;
bool platoIsOK = validateStudent(plato);

无疑地 Student 的copy 构造函数会被调用,以plato 为蓝本将s初始化。同样明显地,当 validateStudent 返回s会被销毁。因此,对于函数而言,参数的传递成本是“一次 Student copy 构造函数调用,加上一次 Student 析构函数函数调用”。
但这并不是全部,Student 对象内有两个 string 对象,所以每次构造一个Student 对象就构造了两个string 对象。此外 Student 对象继承自Person 对象,所以每次构造Student对象也必须构造出一个Person对象。一个Person对象有两个string对象在其中,因此每一次Person 构造动作又需要调用两次string 构造动作。最终结果是,以“值传递”方式传递一个Student 对象会导致调用一次Student copy构造函数、一次Person copy构造函数,一次string copy构造函数。当函数内的那个 Student 副本被销毁,每个构造函数调用动作都需要一个对应的吸狗函数调用动作。因此,以“值传递”方式传递一个 Student 对象,总体成本是“六次构造函数和六次析构函数”!

“不可变引用传递”的性质

虽然有成本,但是行为是正确的,代码没有什么异味。如果可以避免这么多构造函数和析构函数动作就好了。可以的,使用 “不可变引用传递”:

bool validateStudent(const Student &s);

这种传递方式效率高得多:没有任何构造函数或析构函数函数被调用,因为没有任何新对象被创建。原先的 validateStudent 以“值传递”方式接受一个Student参数,因此调用者知道他们受到保护,函数内绝对不会对传入的Student做任何改变;validateStudent 只能够对其副本做修改。现在Student 以 by reference 方式传递,将它声明为 const 是必要的,因为不这样做的话调用者会忧虑 validateStudent会不会改变他们传入的那个值在函数内会不会被改变。

对象分割

以 “引用”方式传递参数也可以避免“对象分割”问题,当一个派生类对象以“值”方式传递并被视为一个基类对象,基类的copy构造函数会被调用,而“造成此对象的行为像个 派生类对象”的那些性质全部被切割掉了,仅仅留下一个基类对象。这实在不怎么让人惊讶,因为正是基类函数建立了它。但这个绝不会是你想要的。

举个例子:

class Window {

public:
  std::string name() const;
  virtual void dispaly() const;
};


class WindowWithScrollBars : public Window {
   
public:
  virtual void display() const;
};

所有Window 对象都带有一个名称,你可以通过Name 函数取得它。所有窗口都可以现实,你可以通过dispaly 函数完成它。display 是个虚函数,这意味简易朴素的基类Window 对象的现实方式和高贵华丽的 WindowWithScrollBars 对象的显示方式不同。

现在假设你希望写个函数打印窗口名词,然后现实该窗口。下面是错误的示范。

void printNameDisplay(Window w) {
  std::cout<<w.name();
  w.dispaly();
}

当你调用上述函数并交给一个WindowWithScrollBars 对象,会发生什么事呢?

WindowWithScrollBars wwsb;
printNameDisplay(wwsb);

喔,参数w会被构造成为一个Window 对象,它是“值传递”,wwsb 除 Window 对象之外的所有信息都会被切割。因此在 printNameDisplay 内调用dispaly 总是Window:: dispaly, 绝对不会是WindowWithScrollBars:: dispaly.
解决“对象切割”问题的办法就是以“不可变引用”的方式传递w:

void printNameDisplay(const Window &w) {
  std::cout<<w.name();
  w.dispaly();
}

现在,传进来的窗口是什么类型,w就表现出那种类型。

如果窥探C++ 编译器底层,你会发现,引用 往往以指针实现出来,“引用传递”通常真正传递的是指针。因此如果你有个对象属于内置类型(如int),“值传递”往往比“引用传递”的效率高些。对于内置类型而言,当你有机会选择采用 值传递 或 不可变引用传递 时,选择 值传递 并非没有道理。这个忠告也适用于 STL的迭代器和函数对象,因为习惯上他们都被设计为 值传递。迭代器和函数对象的实践者有责任看看他们是否高效运行且不受切割问题的影响。

一般而言,你可以合理假设“值传递”并不昂贵的唯一对象就是内置类型和迭代器和函数对象。至于其他任何东西都请遵循本条款的忠告,尽量以 “不可变引用传递” 替换 “值传递”。

必须返回对象时,别妄想返回其 reference

请记住

  • 绝不要返回 指针或者引用指向一个局部对象,或者返回一个指针或引用指向一个局部静态对象,而有时同时需要多个这样的对象
    前面我们讲了“值传递”的性能损耗,一心一意根除“值传递”带来的种种问题,一味的追求“引用传递”纯度,那么就会犯下一个严重的错误:“引用指向其实并不存在的对象”,这可不是什么好事。

我们来看一下下面这段代码:

class Rational {
public:
    Rational(int numerator = 1, int deniminator = 1);
    
private:
    int n,d;
    const Rational operator *(const Rational &lhs, const Rational &rhs){
        Rational result(lhs.n*rhs.n, lhs.d*rhs.d);
        return result;
    }
};

在stack空间创建一个局部对象

上面的 operator* 函数通过“值传递”返回一个 Rational 对象,为了优化性能,使用“引用传递”替换“值传递”, 代码如下:

class Rational {
public:
    Rational(int numerator = 1, int deniminator = 1);
    
private:
    int n,d;
    const Rational& operator *(const Rational &lhs, const Rational &rhs){
        Rational result(lhs.n*rhs.n, lhs.d*rhs.d);
        return result;
    }
};

分析一下这个 operator* 函数,接收两个“不可变引用”,返回一个“不可变引用”,返回值引用指向在operator* 函数中创建的局部对象。函数中创建的局部对象,函数执行完之后局部对象就会被销毁,所以函数并没有返回引用指向某个Rational 对象 ,这时,无论我们对这个引用做任何运用,都将导致“无定义行为”。

将变量声明为 private

请记住

  • 切记将成员变量声明为ptivate。这可赋予哭户访问数据的一致性、可席位划分访问控制、允许约束条件获得保证,并提供class作者以充分实现的弹性。
  • protected 并不比public更具封装性。

下面是我的规划。首先带你看啊看成员变量为什么不该是public,然后让你看看所有反对public成员变量的论点同样适用于protected 成员变量。最后得出成员变量只能是 private。
用 访问语法的一致性、精细控制访问权限、封装 三点来分别论证。

  1. 数据访问语法的一致性
    如果成员变量不是 public,客户端访问该变量的唯一途径就只有成员方法。如果public 接口内的每样东西都是函数,客户端访问class 成员时就不需要考虑是否使用小括号。他们需要做的就只有函数调用,也就是都加括号。
  2. 精细控制访问权限
    使用函数可以使成员变量的处理有更精确的控制。如果令成员变量为 public ,每个人都可以读写它,但如果你以函数取得或设定其值,你就可以实现出“不准访问”、“只读访问”、“读写访问”、“只写访问”。举个例子,如下代码:
class AccessLevels {
public:
    int getReadOnly() const {return readOnly;}
    void setReadWrite(int value) {readWrite = value;}
    int getReadWirte() const {return  readWrite;}
    void setWriteOnly(int value) {writeOnly = value;}
private:
    int noAccess;//外部不允许访问
    int readOnly;// 外部只读权限
    int readWrite;// 允许外部读写权限
    int writeOnly;// 外部只写权限
};
  1. 封装
    如果通过函数访问成员变量,日后可以改以某个计算替换这个成员变量,而客户端确不知道内部实现已经发生了变化。
    举个例子,获取人的年龄,按照周岁来计算:
class Person {
public:
    void setAge(int value) { age = value;}
    int getAge() const {return age;};
private:
    int age;
};

在很多地方年龄是按照虚岁来算的,我们要把获取的年龄变为虚岁,代码可以这样改,而客户端的调用不做任何改变:

class Person {
public:
    void setAge(int value) { age = value;}
    int getAge() const {return age + 1;};
private:
    int age;
};

封装性是很重要的,如果你对客户端隐藏成员变量(也就是封装了他们),你可以确保class 的约束条件总是会获得保护因为只有成员函数可以影响它们。同时,保留了日后变更实现的权利。如果不隐藏他们,你很快会发现,改变任何public成员的能力受到了束缚,因为这会破坏客户端的代码。public 意味着不封装,不封装意味着不可改变。所谓改变,可以理解成修改成员变量的名称、类型或者从class 中移除。
假设我们有一个public 成员变量,现在我们将其从class 中移除,所有使用到该成员的客户端代码都将被破坏。假设我们有一个 protected 成员变量,而我们最终将其从 class 中移除,那么所有继承至它的class 都将被破坏。因此可以得出结论:protected 成员变量跟public 成员变量一样缺乏封装性。这两种情况下,如果成员变量被改变,都会有不可预知的大量代码受到破坏。

  1. 对于protected 成员变量?
    跟public 成员相同,“语法一致性”、“精细控制访问权限”等同样适用于protected。那“封装性”protected 是否高于 public?并非如此!
    记住“封装性”与“当内容改变时可能造成的代码破坏量”成反比。因此“成员变量的封装性”与“成员变量的内容发生改变时所破坏的代码数量”成反比。

避免返回 handles 指向对象内部成分

请记住:

  • 避免返回handles(饮用、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const 成员函数的行为就像个const,并将发生“悬垂指针”的可能性降至最低。

避免返回handles 指向对象内部,否则会给程序带来不可预知的问题。我将通过封装性、安全性两个方面来描述这个问题。

封装性

先来看一下示例代码:

class Point {
public:
    Point(int _x, int _y):x(_x), y(_y) {}
    
    int x{0};
    int y{0};
};

struct RectData {
    Point ulhc;// 左上角
    Point lrhc;// 右下角
};

class Rectangle {

public:
    Point &upperLeft() const {return pData->lrhc;}
    Point &lowerRight() const {return pData->ulhc;}
private:
    std::shared_ptr<RectData> pData;
};

代码中有 Point、RectData、Rectangle三个类:

  1. Point:表示二维坐标点
  2. RectData:表示区域数据,区域由左上角、右下角两个坐标点构成
  3. Rectangle:表示矩形,矩形内封装了区域数据以及相关操作。
    客户端需要计算Rectangle的范围,所以 Rectangle 提供upperLeft、lowerRight两个函数,因为之前说过 使用 “引用传递”往往会比使用“值传递”性能更好,所以这两个函数返回的是 “引用类型”.

上面的表面上看没啥问题,但实际上是很离谱的,upperLeft、lowerRight被声明为const,其目的是不让修改 Rectangle。但函数返回的是内部private 成员的 引用,调用者可以通过返回的引用修改其成员变量,这就跟目的产生了矛盾:

Point &p = rect.upperLeft();
p.x = 5;

上面这种问题,其实可以很容易的解决,upperLeft、lowerRight 的返回值类型修改为“不可变引用”,这样外部就不能修改 Rectangle内部成员, 修改代码如下:

class Rectangle {

public:
    const Point &upperLeft() const {return pData->lrhc;}
    const Point &lowerRight() const {return pData->ulhc;}
private:
    std::shared_ptr<RectData> pData;
};

很完美!

安全性

但即使像上面那样,upperLeft 和 lowerRight 还是返回了代表对象内部的 handles,有可能在其他场合带来问题。更明确的说,这将可能导致“悬垂指针”,这种指针所指向的东西不复存在。例如某个函数返回GUI的外框,这个外框采用矩形形式:

class GUIObject {};
const Rectangle boundingBox(const GUIObject & obj);

现在客户端有可能这么用这个函数:

    GUIObject *pgo;
    const Point *pUpperLeft = &(boundingBox(*pgo).upperLeft());

对 boundingBox 的调用获得一个新的、暂时的 Rectangle 对象。这个对象没有 引用指向 temp。随后uperLeft 作用于 temp 身上,返回一个只想 temp 的一个内部的 一个 point。于是 pUpperLeft 指向那个 point 对象。目前为止一切还好,但故事尚未结束,因为在那个语句结束之后,boundingBox的返回值,也就是我们所说的temp将被销毁,而那间接导致temp内的所有成员析构。最终导致 pUpperLeft 指向一个不存在的对象;也就是说一旦产出 pUpperLeft 的那个语句结束, pUpperLeft 也就变成“悬垂指针”。
这就是为啥,函数如果返回“一个handle代表对象内部成分”总是危险的原因。不论这所谓的handle是个指针、迭代器、引用,也不论这个handle是否为const,也不论那个返回handle的成员函数是否为 const。这里最关键是有个handle被传出去了,一旦如此你就暴露在“handle 比其所指对象更长寿”的风险下。

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

请记住

绝不要重新定义继承而来的 非虚函数。
咋们来看下面这段代码:

class B {
public:
    void mf();
};

class D: public B {
public:
    
};


int main(int argc, const char * argv[]) {
    D x;
    B *pB = &x;
    pB->mf();
    
    D *pD = &x;
    pD->mf();
    return 0;
}

mf 函数的两次调用都是通过x调用。由于两者所调用的函数都相同,凭借的对象也相同,所以行为也应该相同,是吗?

是的,理应如此,但事实可能不是如此。更明确地说,如果 mf 不是虚函数,而D 又定义了自己的 mf 版本,就像这样:

class D: public B {
public:
pD->mf();//覆盖率 B::mf
};

pB->mf();// 调用 B::mf
pD->mf();// 调用 D::mf

造成此一两 面行为的原因是,非虚函数都是静态绑定,当mf被调用,任何一个D对象都可能表现出B或者D的行为,具体取决于只想该D对象的指针类型。另一方面,虚函数却是动态绑定,所以他们不受这个问题的苦。

绝不重新定义继承而来的缺省参参数

请记住

  • 绝对不要重新定义一个继承而来的缺省参数值,因为参数值都是静态绑定的,而virtual函数--你唯一应该覆写的函数--却是动态绑定。

我们假设只能继承 non-virtual 函数和 virtual 函数。上一条我们说过了,绝不要重新定义继承来的non-virtual 函数。那么我们就只需要讨论 virtual函数。
virtual 函数是动态绑定,而缺省参数值却是静态绑定。
正确的代码应该这样写,举个例子:

class Shape {
public:
    enum ShapeColor {Red, Green, Blue};
    void draw(ShapeColor color = Red) {
        doDraw(color);
    }
private:
    virtual void doDraw(ShapeColor color) {// 真正实现
        
    }
};

class Rectangle:Shape {
public:
    //...
private:
    void doDraw(ShapeColor color) override {// 不需要制定缺省参数
        //。。。
    }
};

总结

拖延症严重,整理了一个多月,可算是整理完了。《EffectiveC++ 》整本书一共55个改善程序与设计的具体做法,每个条款都很不错;但是有的条款已经过时了,做法不太适合基于C++11 开发,这个需要自己做甄别。《EffectiveC++ 》和《C++ Primer》看完之后对自己的技术也有了很大的提升,组内那些大佬写的代码基本上也都看得懂了,知道他们为什么要那么写。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容