Effective C++第四部分 设计与声明

让软件可以被正确使用,不易被误用

接口开发首先需要考虑客户可能做出什么样的错误
假设设计一个表现日期的class

class Date {
public:
   Date(int month, int day, int year);
   ...
};

该类第一可能以错误的次序传递参数,第二可能传递一个无效的月份或天数

许多客户端错误可以因为导入新类型而获得预防, 因此可以导入外覆类型(wrapper types)来区别天数、月份和年份,然后再Date构造函数中使用这些类型:

struct Day{
explicit Day(int d)
  : val(d) { }
int val;
};
... //定义Month和Year
class Date {
public:
   Date(const Month& m, const Day& d, const Year& y);
   ...
};
Date d(30, 3, 1995); //错误,不正确类型
Date d(Day(30), Month(3), Year(1995)); //错误,不正确类型
Date d(Month(3), Day(30), Year(1995));//正确

同时,令年月日成为充分锻炼的class并封装其内数据, 比简单使用上述的struct好,即使struct也足够说明:明智审慎地导入新类型对预防接口被误用有显著的效果。

一旦正确的类型确定,那么限制其值也有合理性,比如与预先定义有效的Months:

class Month{
public:
   static Month Jan() { return Month(1); }
   static Month Feb() { return Month(2); }
   ...
   static Month Dec() { return Month(12); }
   ...
private:
   explicit Month(int m);
   ...
};
Date d(Month::Mar(), Day(30), Year(1995));

预防客户出现错误的另外一个办法是,限制类型内什么事情可做,什么事情不能做,常见的限制是加上const。

为了让types容易被正确使用,不容易被误用,所以应遵循除非有好理由, 否则应该尽量令你的types的行为与内置的types行为一致,避免无端与内置内省不兼容,真正的理由是为了提供行为一致的接口,很少有其他性质比得上一致性更能导致结构容易被正确使用

任何借口如果要求客户必须记得做某些事情,就有被“不正确使用的倾向”,因为客户可能忘记做那件事情,所以在资源管理函数中要引入智能指针。

tr1::shared_ptr有一个特别好的性质是, 它会自动使用它的“每个指针的专属删除器”, 因而消除了另外一个潜在的客户错误,即“cross-DLL problem”。这个问题发生在“对象在动态连接程序库(DLL)中被new创建,却在另一个DLL中被delete销毁”。在许多平台上,这一类“跨DLL值new/delete成对运用”会导致运行期错误。tr1::shared_ptr没有这个问题,因为它默认的删除器是来自"tr1::shared_ptr"诞生所在的那个DLL的delete。

例如,当Stock派生自Investment而createInvestment实现如下:

std::tr1::shared_ptr<Investment> createInvestment()
{
   return std::tr1::shared_ptr<Investment>(new Stock);
}

返回的那个tr1::shared_ptr可被传递给任何其它的DLL, 无需在意“cross-DLL
problem”。这个指向Stock的tr1::shared_ptr会追踪记录“当Stock的引用次数变成0时该调用那个DLL‘s”的delete。

最常见的tr1:::shared_ptr来自Boost。Boost的shared_ptr时原始指针(raw pointer)的两倍大, 以动态分配内存作为薄记用途和“删除器之专属数据”,以virtual形式调用删除器,并在多线程程序修改引用次数时蒙受==线程同步化(thread synchronization)==的额外开销。(只要定义一个预处理器符号就可以关闭多线程的支持)。总之,它比原始指针大且慢,使用辅助动态内存。在很多场景下这些成本并不显著,但是可以有效降低客户错误。

促进正确使用的办法包括接口的一致性,以及与内置类型的行为兼容

组织误用包括建立新类型,限制类型上的操作,束缚对象值以及消除客户的资源管理责任

tr1::shared_ptr支持定制型删除器(custom deleter)。这可防范DLL问题,可被用于自动解除互斥锁(mutexes)等等

像设计type一样设计Class

C++就像在其它OOP语言一样, 当定义一个新的class, 就定义了一个新的type。因此设计一个新的class时需要回答以下这些问题:

  • 新typr的对象应该如何被创建和销毁
  • 对象的初始化和对象的赋值应该又什么样的差别(不能混淆“初始化”和“赋值”,因为他们对应不同的函数调用。)
  • 新type的对象如果被passed by value(以值传递),意味着什么?
  • 什么时type的合法值?
  • 新的type需要配合某个继承图系嘛?(inheritance graph)
  • 新的type需要什么样的转换
  • 什么样的操作符和函数对此新type而言是合理的
  • 谁该取用新type的成员
  • 什么是新type的“未声明接口”(undecalred interface)
  • 新的type有多么一般化(如果定义的是一横个type家族,就应该定义一个新的class template)
  • 真的需要一个新的type嘛

尽量以pass-by-reference替代pass-by-value

默认情况下C++以by value方式传递对象至函数,除非另外指定,函数参数都是以实际实参的复件(副本)为初值,调用端所得到的也是函数返回值的一个复件,这些复件有对象的copy构造函数产出,这可能使得pass-by-value成为昂贵的操作:

class Person{
public:
   Person();
   virtual ~Person();
   ...
private:
   std::string name;
   std::string address;
};
class Student : public Person{
public:
   Student();
   ~Student();
   ...
private:
   std::string schoolName;
   std::string schoolAddress;
};

假如一个调用函数validateStudent需要一个Student实参(by value)并返回它是否有效。

bool validateStudent(Student s);
Student plato;
bool platoIsOK = validateStudent(plato);

当调用以上函数时,需要调用一次Student copy构造函数,一次Person copy构造函数,四次string copy构造函数,当销毁时,有需要调用对应的析构函数。所以,总体成本时六次构造函数和六次析构函数
当使用“pass by reference-to-const”时:

bool validateStudent(const Student & s);

这种传递比较高效,但是const的声明是非常重要的。当以by value的形式传参时,调用者知道函数不会对传入的Student做任何改变,validateStudent只能对Student的复件做修改,但是以by reference形式传递时,将其声明为const是必要的,否则调用者会忧虑validateStudent有可能会改变传入的Student。

以by reference方式传递参数也可以避免slicing(对象切割)问题。当一个derived class对象以by value方式传递并被视为一个基类对象,基类的copy构造函数会被调用,特化性质全被切割掉了,仅留下了一个基类对象。解决切割问题的方法,就是以by reference-to-const的方式传递:

void printNameAndDisply(const Whindow& w)
{
   std::cout << w.name();
   w.display();
}

现在,传进的参数是什么类型,w就表现出什么类型。
在C++编译器的底层,reference是以指针进行实现的,当使用的对象为内置类型或STL的迭代器和函数对象时,pass by value往往比pass by refeerence的效率高。

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

当尽可能追求函数传参全部以pass-by-reference进行时,有可能将一些reference指向其实并不存在的对象。
例如某哦个表现有理数的类:

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

这个版本的operator是以by value的方式返回其计算结果。如果改为传递reference,就不需要付出代价。但是reference代表某个既有对象,当operator返回一个reference,就一定指向某个既有的Rational对象,内含两个Rational对象的乘积。假如:

Rational a(1, 2);
Rational b(3, 4);
Rational c = a * b;

如果operator*要返回一个reference指向如此数值,就必须自己创建那个Rational对象。
创建对象的途径有两种:1.在stack空间建立2.在heap空间建立,如果定义一个local变量,就是在stack空间创建对象。

const Rational& operator* (const Rational& lhs, const Rational& rhs)
{  
    Rational result(lhs.n * rhs.n, lhs.d * rhs.d); //糟糕的代码
    return result;
}

首先这种写法需要使用构造函数进行构造。第二result是一个local对象,而local对象在函数退出前被销毁了,所以这个版本的operator*并未返回reference指向某一个Rational,它返回的reference指向一个已经被销毁的Rational。任何函数如果返回一个reference指向一个local对象,都将一败涂地。(返回指针也是如此)
接下来在heap中构造一个对象,如下所示:

const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
   Rational* result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
   return *result;
}

此种写法将造成资源泄露,很多时候无法执行delete,例如:

Rational w, x, y, z;
w = x * y * z;

上述两种方法都因为调用构造函数而收到惩罚,下面这种写法奠基于“让operator*返回的reference指向一个被定义于函数内部的static Rational对象”:

const Rational& operator* (const Rational& rhs, const Rational& lhs)
{
   static Rational result;
   result = ...;
   return result;
}

引入static可能会造成对多线程安全性造成威胁,除此之外还有更深层次的瑕疵,客户写出如下代码时

bool operator==(const Rational& lhs, const Rational& rhs);
Rational a, b, c, d;
...
if ((a * b) == (b * c)) {
   乘积相等对应动作;
}else{
   乘积不等对应动作;
}

表达式((a * b) == (b * c))总是被核算为true。实际上上述代码可以被写为:

if(operator==(operator*(a, b), operator*(c, d)))

在operator= =被调用前,已有两个operator调用式起作用,每一个都返回reference指向operator对应的static Rational对象。因此operator= =被要求将“operator*”内的static Rational对象值作比较,所以一直为真(两次operator*调用确实改变了各自static Rational的对象值,但是它们返回的都是reference,调用端看到的永远时static Rational对象的现值)
一个“必须返回新对象”的正确写法是让函数返回一个新对象:

inline const Rational operator*(const Rational& lhs, const Rational& rhs)
{
   return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}

当然这种写法需要承担额外的构造成本和析构成本。

绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer指向一个local static对象而有可能同时需要多个这样的对象。

将成员变量声明为private

不采用public成员变量的理由:
1.语法的一致性:如果成员变量不是public,客户唯一能够访问对象的方法就是通过成员函数。
2.对成员变量的处理有更精准的控制,可以实现“不准访问”,“只读”,“读写”,“只写”,如下所示

class AccessLevels {
public:
   ...
   int getReadOnly() const { return readOnly; }
   void setReadWrite(int value) { readWrite = value; }
   int getReadWrite( ) const { return readWrite; }
   void setWriteOnly(int value) { writeOnly = value; }
private:
   int noAccess; //对此int无任何访问动作
   int readOnly; //对此int做只读访问(read-only access)
   int readWrite; //对此int做读写访问
   int writeOnly;
}

3.封装:通过函数访问成员变量,日后可以某个计算替代这个成员变量。

切记将成员变量声明为private,这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证性,并提供class作者以充分的实现弹性。

protected并不比public更具有封装性

尽量使用non-member、non-friend替换member函数

面向对象守则要求数据应该被尽可能的封装,member函数所带来的封装性比non-member函数低。另外,non-member函数具有较大的包裹弹性(packaging flexibility),最终导致较低的编译相依度。

封装的意义:它是我们能够改变事务而只影响有限客户。

当成员变量为private时,能够访问它的只有member函数和friend函数,所以当在一个member函数(可以访问private数据,可以取用private函数、enums、typedefs等等)和一个non-member函数做选择时,non-member函数能够提供较大的封装性。

有两点需要注意;
1.该论述只适用于non-member、non-friend函数,friend函数对class private成员的访问权力和member函数相同。
2.在意封装性而让函数“成为class的non-member”并不意味它“不可以是另一个class的member”。在C++中,比较自然的做法是让clearBrowser成为一个non-member函数并且位于webbrowser所在的同一个namespace内:

namespace WebBrowserStuff{
   class WebBrowser { ... };
   void clearBrowser (WebBrowser& wb);
   ...
}

namespace和class不同,前者可跨越多个 源码文件而后者不能。
一个WebBrowser的类可能拥有大量便利函数,可以将针对不同功能的函数 分别声明于不同的头文件:

//头文件“webbrowser.h”这一头文件针对class WebBrowser自身及WebBrowser核心机能
namespace WebBrowserStuff {
class WebBrowser {...};
   ... //核心机能,如几乎所有客户都需要的non-member函数
}
//头文件“webbrowserbookmarks.h”
namespace WebBrowserStuff {
   ... //书签相关的便利函数
}
//头文件“webbrowsercookies.h”
namespace WebBrowserStuff {
   ...     //与cookie相关的便利函数
}

将所有便利函数放在多个头文件内但是隶属同一个命名空间,意味客户可以轻松扩展这一组便利函数

如果所有参数都需要类型转换,请为此采用non-member函数

令类支持隐式类型转换通常来讲是糟糕的。 但是也有例外,比如建立数值类型时。

class Rational {
public:
   Rational(int numerator = 0, int denominator = 1); //构造函数可以不为explicit,允许int-to-Rational隐式转换
   int numerator() const;
   int denominator() const;
private:
   ...
};

讲operator*写成Rational成员函数的写法:

class Rational {
public:
   ...
   const Rational operator* (const Rational& rhs) const;
};

该设计使人能够将两个有理数以最轻松自在的方式相乘:

Rational oneEighth(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEighth;
result = result * oneEighth;

当设计混合式运算时,

result = oneHalf * 2 //成功
result = 2 * oneHalf //失败

将其重写,出现的问题就一目了然:

result = oneHalf.operator*(2);
result = 2.operator*(oneHalf);

第一行中发生了隐式类型转换(implicit type conversion).编译器知道传递的是一个int,函数需要的使Rational, 他知道只要调用Rational构造函数就可以将int变为Rational,如下所示:

const Rational temp(2);
result = oneHalf * temp;//因为我们在定义Rational的构造函数时刻意的没有使用explicit。

然后上述的两行中只有第一行成功,第二行没有成功,原因在于只有当参数位于参数列(parameter list)内,这个参数才是隐式类型转换的合格参与者。

将operator*成为一个non-member函数,就会允许编译器在每一个实参身上执行隐式类型转换:

class Rational{
...
};
const Rational operator* (const Rational& lhs, const Rational& rhs) //现在成为了一个non-member函数
{
   return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
   
}
Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2; //成功
result = 2 * oneFourth; //成功

如果需要为某个函数的所有参数(包括this指针所指的那个隐喻函数)进行类型转换,那么这个函数必须是个non-member。

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

swap原本时STL的一部分,后来成为异常安全性编程(exception-safe programming)的脊柱,以及用来处理自我赋值可能性的一个常见机制。

默认的swap函数典型实现如下:

namespace std {
   template<typename T>
   void swap( T& a, T& b)
   {
       T tempa(a);     
       a = b;
       b = temp;      
   }
}

只要类型T支持copying,默认的swap就会帮用户置换类型为T的对象。

当类型为“以指针指向一个对象,内含真正数据”时,这种设计的常见表现形式时所谓“pimpl”手法(pointer to implementation)。如果以pimpl手法设计Widget class,如下所示:

class WidgetImpl {
public:
   ...
private:
   int a, b, c;
   std::vector<double> v; //可能有很多数据,复制时间长
}
class Widget { //这个类使用pimpl手法
public:
   Widget(const Widget& rhs);
   Widget& operator=(const Widget& rhs) //复制Widget时,令它复制其WidgetImpl对象
   {
       ...                        //关于operator=的一般性实现细节
       *pImpl = *(rhs.pImpl);     
       ...
   }
   ...
private:
   WidgetImpl* pImpl;     //指针,所指对象内涵Widget数据
}

一旦要置换两个Widget对象值,我们唯一需要做的就是置换其pImpl指针,但是默认的swap算法无法做到这一点。

实践这一思路的做法时:将std::swap针对Widget特化。但是目前这个形式无法通过编译:

namespace std{
   template<>
   void swap<Widget> (Widget& a, Widget& b)
   {
       swap(a.pImpl, b.pImpl);
   }
}

该方法企图访问a, b内的pImpl指针,然而其是private的。
我们令Widget声明一个名为swap的public成员函数做真正的置换工作,肉厚将std::swap特化,令它调用该成员函数:

class Widget {
public:
   ...
   void swap(Widget& other)
   {
       using std::swap;
       swap(pImpl, other.pImpl);
   }
   ...
};
namespace std {
   template<>
   void swap<Widget>(Widget& a, Widget& b)
   {
       a.swap(b);
   }
}

当Widget和WidgetImpl都是class template而非classes,也许我们可以试将WidgetImpl内的数据类型加以参数化。

当我们偏特化(partially specialize)一个function template(std::swap),但c++只允许对class template偏特化,在function templates上偏特化是行不通的。我们可以声明一个non-member函数让其调用member版本,但是不讲那个non-member swap声明为std::swap的特化版本或者重载版本。为求简化起见,假说Widget的所有相关机能都被置于命名空间WidgetStuff内:

namespace WidgetStuff {
   ...
   template<typename T>
   class Widget { ... };
   ...
   template<typename T>
   void swap(Widget<T>& a, Widget<T>& b)
   {
      a.swap(b);
   }
}

首先,如果swap的默认版本提供了可接受的效率,不需要做任何其它工作。
其次,如果swap的效率不足,那么尝试做以下事:

  1. 提供一个public swap函数,让它高效地置换你的类型的两个对象值(这个函数绝不该抛出异常)
  2. 在class或者template所在的命名空间提供一个non-member swap,并命令它调用上述swap成员函数
  3. 如果你正编写一个class(而非一个class template),为你的class特化std::swap,并令它调用你的swap成员函数。
    最后,如果调用swap函数,需要确定包含一个using声明式,以便让std::swap在你的函数内曝光可见,然后不加任何namespace修饰符,直接调用swap。

成员版swap绝不可抛出异常,因为swap的一个最好的应用事帮助class和class template提供强烈的异常安全新(exception-safety)保障。

当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常

如果你提供一个member swap,也该提供一个non-member swap用来调用前者,对于class,也请特化std::swap.

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

为”用户定义类型“进行std templates全特化时好的,但是千万不要尝试在std内加入某些对std而言全新的东西

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值