EC之Designs and Declarations

条款18:Make interfaces easy to use correctly and hard to use incorrectly

C++中充斥着各种各样的接口,function接口、class接口、template接口……每一种接口都是客户与你的代码互动的手段。假设你面对的是一群通情达理的客户,如果他们用错了某个接口,你也应该为此负一部分责任。理想状态下,如果客户试图使用某个接口但却无法获得他预期的行为,那么客户代码就不该通过编译;一旦代码通过编译,其行为就应该是客户所想要的。

要开发一个不易被误用的接口,首先必须考虑客户可能犯下什么样的错误。假设你为一个用来表现日期的class设计构造函数:

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

乍看之下,这个接口合情合理,其实客户很容易犯下至少两个错误。

第一,他们也许会以错误的顺序传递参数:

Date d(30, 3, 1995); //应该是“3,30”而不是“30,3”

第二,他们也许会传递一个无效的月份或天数:

Date d(2, 30, 1995); //二月没有30日

许多客户端错误可以通过导入新类型而获得预防。现在让我们导入三种简单的类型来区分年、月与日,然后在Date构造函数中使用这些类型:

struct Day {
explicit Day(ind d) : val(d) {}
int val;
};

struct Month {
explicit Month(ind m) : val(m) {}
int val;
};

struct Year {
explicit Year(int y) : val(y) {}
int val;
};


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)); //正确!


当然,上述structs并不成熟,但是作为示范已经足够能说明:导入新类型可以预防接口被误用。

一旦导入新类型,有时候限制其值是合情合理的。例如,一年只有12个月,所以Month就该反映这一事实。我们可以用enum表现月份,但enum不具备类型安全性。比较安全的做法是预先定义所有有效的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有什么样的行为,避免与内置类型的不兼容,就能提供行为一致的接口。很少有什么其他性质比“一致性”更能导致“接口容易被正确使用”,也很少有其他性质比“不一致性”更加剧接口的恶化。STL容器在这方面做得很好,例如每个STL容易都有一个名叫size的成员函数。与此对比的是Java,它允许你对数组使用lengthproperty,对Strings使用lengthmethod,而对Lists使用sizemethod;.NET也一样混乱,其Arrays有个property名为Length,其ArrayLists有个property名为Count。有些开发人员会以为IDE能使这些不一致性不重要,但他们错了。不一致性对开发人员造成的心理和精神上的摩擦与争执,没有任何一个IDE可以完全抹除。

任何接口如果要求客户必须记得做某些事情,就是有着“被误用”的倾向,因为客户可能会忘记做那件事。例如条款13导入了一个factory函数,它返回一个指针指向Investment继承体系内的一个动态分配对象:

Investment* createInvestment(); 

为避免资源泄漏,客户可以将该函数的返回值存储于一个智能指针内,但万一客户忘记使用智能指针怎么办?许多时候,较佳接口的设计原则是先发制人,即令factory函数返回一个智能指针:

std::tr1::shared_ptr<Investment> createInvestment();

总结:

  • 好的接口不易被误用
  • 促进接口被正确使用的办法包括保持接口的一致性,以及使接口与内置类型兼容
  • 阻止接口被误用的办法包括建立新类型、限制类型上的操作,约束对象值,以及消除客户的资源管理责任

条款19:Treat class design as type design

设计优秀的classes是一项艰巨的工作,因为设计好的types是一项艰巨的工作。好的types有自然的语法,直观的语义,以及一或多个高效实现品。

如何设计高效的classes呢?首先你必须了解你面对的问题。几乎每个classes都要求你面对以下提问

  • 新type的对象应该如何被创建和销毁?这会影响到你的class的构造函数和析构函数以及内存分配函数和释放函数(operator new,operator new[],operator delete,和operator delete[])的设计
  • 象的初始化和对象的赋值该有什么样的差别?这决定了你的构造函数和赋值操作符的行为以及其间的差异
  • 新type的对象如果被passed by value,意味着什么?copy构造函数用来定义一个type的pass-by-value该如何实现
  • 什么是新type的“合法值”?对class的成员变量来说,通常只有某些数值集是有效的。那些数值集决定了你的class必须维护的约束条件,也就决定了你的成员函数必须进行的错误检查工作
  • 你的新type需要配合某个继承体系吗?如果你继承自某些既有的classes,你就受到那些classes的设计的约束,特别是受到“它们的函数是virtual或non-virtual”的影响。如果你允许其他classes继承你的class,那会影响你声明的函数——尤其是析构函数——是否为virtual
  • 你的新type需要什么样的转换?如果你需要T1类型的对象可以被隐式转换为T2类型,就必须在class T1内写一个类型转换函数(operator T2)或在class T2内写一个non-explicit-one-argument的构造函数
  • 什么样的操作符和函数对此新type而言是合理的?这个问题的答案决定你将为你的class声明哪些函数。其中哪些该是member函数,哪些则不是
  • 什么样的标准函数应该驳回?那是你该声明为private的函数
  • 谁该取用新type的成员?这个问题帮助你决定成员的访问级别,以及哪些classes或者functions该是friends
  • 什么是新type的“未声明接口”?
  • 你的新type有多么一般化?也许你并非定义一个新type,而是定义一整个types家族。这样,你就该定义新的class template
  • 你真的需要一个新type吗?如果只是定义新的derived class以便为既有的class添加新功能,或许你只需定义一或多个non-member函数或templates就能达成目的

条款20:Prefer pass-by-reference-to-const to pass-by-value

缺省情况下C++by value方式(继承自C)传递对象至函数。除非你另外指出,否则函数参数都是以实参的复件为初值,而调用端获得的也是函数返回值的一个复件。这些复件由对象的copy构造函数产生,因此可能造成昂贵的成本。为了避免不必要的构造和析构动作,我们可以采用pass by reference-to-const。

以by reference方式传递参数还可以避免对象切割的问题。当一个derived class对象以by value方式传递并被视为一个base class对象时,base class的copy构造函数会被调用。

对于内置类型,STL的迭代器和函数对象,pass-by-value比较适当。

条款21:Don't try to return a reference when you must return an object

当程序员意识到pass-by-value的种种不利因素之后,他们会开始一心一意地追求pass-by-reference,这往往会导致一个致命错误:传递一些references指向并不存在的对象。

当你必须在“返回一个reference和返回一个object”之间抉择时,你的工作就是挑出行为正确的那个。

条款22:Declare data members private

首先,我们来看看为什么成员变量不该是public。

让我们从语法一致性开始。如果成员变量是public的话,客户在访问class成员时就必须考虑访问的是一个变量还是函数,是否该加括号;反之,客户只能访问public成员函数,每次访问都需要使用括号。

也许一致性的理由不足以令人信服,那么这个事实如何:使用函数可以让你更精确地控制成员变量。如果让成员变量为public,任何人可以任意地修改它,但如果你用函数来操纵成员变量,你就可以实现出不同的访问级别:

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 readOnly;         //对该成员只读访问
  int readWrite;        //对该成员读写访问
  int writeOnly;        //对该成员只写访问
};

如果上述理由还不能说服你,那只好祭出杀手锏了:封装。

如果你通过函数访问成员变量,那么以后你可以修改内部实现,而不用担心客户代码需要更改,因为class的接口没有变化。

将成员变量隐藏在函数接口的背后,可以为“所有可能的实现”提供弹性。例如这可是的成员变量被读或被写时轻松通知其他对象、可以验证class的约束条件以及函数的前提和事后状态、可以在多线程环境中执行同步控制……等等。

封装的重要性比你最初见到它时还重要。如果你对客户隐藏成员变量(也就是封装它们),你可以确保class的约束条件总是会获得维护,因为只有成员函数可以影响它们。更重要的是,你保留了日后更改实现的权利。public意味着不封装,而不封装几乎意味着不可改变。

protected成员变量的论点十分类似。语法一致性和更精确的访问控制显然也适用于protected数据,那么封装呢?protected成员变量的封装性是否高于public成员变量?答案出人意料:并非如此。

一般来说,某样东西的封装性与其内容改变时造成的代码破坏量成反比。当public成员变量改变时,比如说取消了它,那么所有使用它的客户代码都会被破坏。而protected成员变量改变时,所有使用它的derived classes都会被破坏。因此,protected成员变量和public成员变量一样缺乏封装性。这一反直觉的结论告诉我们,从封装的角度来看,其实只有两种访问权限:private(提供封装)和其他(不提供封装)。

条款23:Prefer non-member non-friend functions to member functions

假设有个class表示网页浏览器。在它提供的众多函数中,有一些用来清除缓存、清除历史记录以及清除cookies:

class WebBrowser {
public:
  ...
  void clearCache();
  void clearHistory();
  void removeCookies();
  ...
};

许多用户会希望同时执行以上所有动作,因此WebBrowser也提供这样一个函数:

class WebBrowser {
public:
  ...
  void clearEverything();  //调用clearCache,clearHistory和removeCookies
  ...
};

当然,这一功能也可由一个non-member函数调用member函数来实现:

void clearBrowser(WebBrowser& wb) {
  wb.clearCache();
  wb.clearHistory();
  wb.removeCookies();
}

那么,哪一个比较好呢?

从面向对象的角度来看,数据以及操作数据的函数应该被捆绑在一起,这意味着member函数是较好的选择。不幸的是,这其实是对面向对象真实含义的误解。面向对象的原则要求数据尽可能地被封装,与直觉相悖,member函数clearEverything的封装性比non-member函数clearBrowser更低。此外,提供non-member函数使得WebBrowser的相关功能有较大的包装弹性,而那最终导致较低的编译依赖度,从而增加WebBrowser的可扩展性。因此在很多方面non-member做法比member做法好。下面我们将仔细探讨其原因。

我们从封装说起。如果某样东西被封装,它的可见性就会降低。封装性越高,可见性就越低。可见性越低,我们改变它的能力就越大,因为改变它仅仅直接影响能看见它的事物。因此, 某样东西的封装性越高,它被改变的弹性就越大。这正是我们推崇封装的原因:它使我们改变事物而只影响有限客户。

现在考虑对象内的数据。越少函数可以访问数据,数据的封装性就越高。在上面的例子中,采用non-member函数clearBrowser减少了能够访问class内private成员的函数数量,因而带来了更高的封装性。

值得注意的是,这里的选择关键并不在member和non-member函数之间,而是在member和non-member non-friend函数之间,因为friend函数对private成员的访问权限和member函数相同。另外,因为封装而让某函数成为class的non-member函数,并不意味着它不可以是另一个class的member函数。例如我们可以让clearBrowser成为某个工具类的一个static member函数。

在C++中,比较自然的做法是让clearBrowser成为一个non-member函数并且位于WebBrowser所在的同一个namespace内:

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


条款24:Declare non-member functions when type conversions should apply to all parameters

通常我们不该让classes支持隐式类型转换。但是也有例外,最常见的例外是在建立数值类型时。假设你设计一个class来表示有理数,那么允许整数隐式转换为有理数相当合理。你的Rational class如下:

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

你希望支持算数运算,但你不确定该由member函数、non-member函数抑或是friend函数中的哪个来实现它们。面向对象的直觉告诉你,你应该让它们成为member,但前一条款曾经反直觉得得出member函数可能会违反面向对象的原则。让我们暂且撇开这些,先来看看让算数运算成为成员函数会发生什么(以乘法为例):

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

支持Rational和int的混合类型运算再合理不过了,但是当你尝试时,却发现只有一半行得通:

Rational oneHalf(1, 2), result;
result = oneHalf * 2;          //正确
result = 2 * oneHalf;          //错误

前次调用实际上等价于:

result = oneHalf.operator*(2);

也就是oneHalf对象调用其成员函数operator*,并且由于int-to-Rational的隐式转换,故调用合法。

而后次调用不成功是因为2并不是对象,并且不存在non-member版本的operator*。

让operator* 成为non-member,就可解决上述问题。

总结:如果你需要为某个函数的所有参数进行类型转换,那么这个函数必须是个non-member

条款25:Consider support for a non-throwing swap

swap函数原本是STL的一部分,但它在异常安全性编程中扮演着重要角色,同时也可用于处理自我赋值。

缺省情况下swap由标准库提供的swap算法完成。其典型实现如下:

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

只要类型T支持copying,缺省的swap实现就会帮你完成置换动作。

但是,对于某些类型而言,缺省的swap实现代价太大。其中最主要的就是采用“pimpl”手法的类型。如果以这种手法设计Widget class,看起来会像这样:

class WidgetImpl {
public:
  ...
private:
  int a, b, c;
  std::vector<double> v;
  ..
};
class Widget {
public:
  Widget(const Widget& rhs);
  Widget& operator=(const Widget& rhs) {
     ...
     *pImpl = *(rhs.pImpl);
     ...
  }
  ...
private:
  WidgetImpl* pImpl;
};

一旦要置换两个Widget对象,其实我们只需置换其pImpl指针。相比std::swap调用多次copying函数,这显然要高效得多。

通常,我们为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);
  }
}

这种做法还保持了与STL容器的一致性,因为所有STL容器也都提供public swap成员函数和std::swap特化版本。

现在假设Widget和WidgetImpl都是class templates而非classes,当我们试着特化std::swap时却出现了错误:

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

这是因为C++只允许对class templates偏特化,而不允许对function templates偏特化。

如果你需要偏特化一个function template,通常可以改为为它添加一个重载版本,像这样:

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

一般而言,重载function template没有问题。但std是个特殊的命名空间,C++禁止客户向其内添加新内容,尽管你可以全特化std内的templates。所以上述代码并不正确。那该怎么办呢?很简单,我们只需要把该function template移出std空间。假设Widget相关功能均置于命名空间WidgetStuff内,我们可以这样处理swap:

namespace WidgetStuff {
  ...
  template<typename T>
  class Widget {...};       //内含swap成员函数
  ...
  template<typename T>
  void swap(Widget<T>& a, Widget<T>& b) {
    a.swap(b);
  }
}

这个做法对classes和template classes都行得通,所以似乎我们应该在任何时候都这么做。但是也有例外会使得你应该为classes特化std::swap(稍候会提到),所以如果想让class专属swap实现尽可能多地被使用,你需要同时写一个non-member版本和一个std::swap特化本版。

下面我们来看看swap的调用。假设你写了一个function template,它需要置换两个对象值。那么它该调用哪个swap呢?我们希望的应该是优先调用类型专属swap,并在其不存在的情况下调用std::swap,所以我们的代码应该看起来像这样:

template<typename T>
void func(T& a, T& b) {
  using std::swap;      //令std::swap在作用域中可见
  ...
  swap(a, b);
  ...
}

假设你以这种方式调用swap:

std::swap(a, b);

这会迫使编译器使用std内的swap版本。这正是你为你的classes全特化std::swap的原因:它使得类型专属的swap版本也可以被不太适当的代码所用。

值得注意的是,成员版的swap绝不可抛出异常。毕竟,成员swap函数的其中一个应用就是为classes提供异常安全性的保障,如果其本身都不是异常安全的,何来保障呢?


总结:

  • 当std::swap效率不高时,提供一个不抛出异常的swap成员函数
  • 如果你提供一个member swap,也应该提供一个non-member swap用来调用前者。对于classes,请同时特化std::swap
  • 调用swap时应使用using声明使std::swap可见
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值