C++ API 设计 11 第六章 C++用法

第六章 C++用法

本章将深入探讨如何使用C++来编写高品质的API。第二章所涉及的通用API品质可以适用于任何编程语言:包括如下概念,隐藏私有细节、容易使用、松耦合和最低限度完整性优于使用任何其它特殊的编程语言。当我讲述这些C++主题的每个细节时,这些概念本身并不是只针对特定语言的。

不过,有很多特定的C++风格会影响API的品质,如使用名空间、运算符、友元和const correctness译者注:可参见Effective C++条款21常量正确性。我将在本章更多地讨论这些问题。

请注意:我把一些与性能有关的C++主题推迟到下一章,如内联和const correctness常量正确性

6.1 名空间

名空间是一个唯一符号的逻辑分组。它提供了一种方法来避免命名冲突,使两个API不会试图去定义具有相同名称的符号。例如,如果两个API都定义了一个叫String的类,那么你就不能在同一个程序中同时使用这两个API,因为在任何时间都只能存在一个定义。你应该总是在API中使用名空间的某种形式来确保用户可以使用其它API。

有两种流行的方式可以往API中添加名空间有两种流行的方式。第一种是使用一个唯一的前缀添加给所有公共的API符号。这是一种很普通的方法,也可以使用在C API上。这种类型的API的例子有很多,包括:

qOpenGL API使用“gl”前缀在所有的公共符号上。例如:glBegin()、glVertex3f()和GL_BLEND_COLOR(Shreiner, 2004)。

qQt API 使用“Q”前缀在所有的公共命名上。例如:Qwidget、Qapplication和Q_FLAGS。

qlibpng库使用“png”前缀在所有的标识符上。例如:png_read_row()、png_create_write_struct()和png_set_invalid()。

qGNU GTKþ +API使用的是“gtk”前缀。例如:gtk_init()、gtk_style_new()和GtkArrowType。

q第二人生Second Life源码使用“LL”前缀(Linden Lab的简写)在各种类、枚举、常量上。例如:LLEvent、LLUUID和LL_ACK_FLAG。

qNetscape可移植运行时给所有的导出类型使用“PR”前缀。例如:PR_WaitCondVar()、PR_fprintf()和PRFileDesc。

q 

第二种方法是使用C++的namespace关键字。这从本质上是把所有的名称都定义在给定的前缀标识符中。例如:

[代码 P174 第一段]

namespace MyAPI

{

class String

{

public:

String();

...

};

}

本例中的String类现在应该通过MyAPI::String来引用。这种风格的好处就是你不需要给每个类、函数、枚举或常量使用一致的前缀:这已经由编译器替你完成了。STL就是采用这种方法,所有的容器类、迭代器和所包含的算法都是在“std”名空间中。你还可以创建嵌套名空间,构成名空间树。例如,英特尔的线程构建模块(Intel’s Threading Build Blocks,TBB)API给所有的公共符号使用“tbb”名空间,而内部的代码使用“tbb::strict_ppl”。Boost库也有使用嵌套名空间来代替“boost”根名空间(root namespace)。例如,boost::variant包含Boost变体的公共符号和boost::detail::variant包含API的内部细节。

使用名空间属性可以生成冗长的符号名,特别是符号包含在几个嵌套的名空间中。不过,C++通过using关键字提供了一种更方便的方式来使用名空间中的符号:

[代码 P174 第二段]

using namespace std;

string str("Look, no std::");

或者采用下面这种更可取的方式(因为它限制了全局名空间导入太多的符号)

[代码 P174 第三段]

using std::string;

string str("Look, no std::");

注意:你千万别在公共API头文件的全局范围内使用using关键字!这么做的话会导致在全局名空间中所引用的名空间的所有符号都变成可见的。这会破坏起初使用名空间的全部意图(Stroustrup, 2000)。如果你要在头文件中引用另一个名空间的符号,那么总是要使用完全限定名(fully qualified name)。例如:std::string。

提示

总是要通过一致的命名前缀或C++ namespace关键字来给API符号提供一个名空间。

6.2 构造函数和赋值

如果你创建一个包含状态的对象,允许用户程序复制或赋值(有时叫做值对象),那么你需要考虑修改构造函数和赋值运算符的设计。如果你自己未曾定义它们,那么编译器会自动生成这些方法的默认版本。不过,如果你的类有动态分配对象的话,那么你必须显式地定义这些方法,用来保证对象可以被正确地复制。具体点说,编译器生成的默认版本方法有如下四种:

q默认构造函数:构造函数是在使用new调用分配对象后,用来初始化对象的。你可以定义包含不同参数的多个构造函数。默认的构造函数可以是不带参数的(这可以是不带参数的构造函数或是所有参数指定默认值的构造函数)。如果你没有显式定义的话,C++编译器会生成一个默认的构造函数。

q析构函数:析构函数会在调用delete时执行,用来释放对象所持有的任何资源。每个类都只能有一个析构函数。如果你没有指定一个构造函数,那么C++编译器会自动生成一个。编译器还会给所有的成员变量自动生成调用析构函数的代码,顺序和它们在类中声明的顺序相反。

q拷贝构造函数:拷贝构造函数是一种特殊的构造函数,用来从一个已知的对象来创建一个新的对象。如果你没有定义拷贝构造函数的话,那么编译器自动会生成一个,为已知的对象成员变量执行浅表复制(shallow copy)。因此,如果对象有分配资源的话,你很可能需要一个拷贝构造函数来来执行深度复制。当有下面的情况下发生时,就会调用拷贝构造函数:

n对象通过值传递或值返回来传给一个方法。

n对象使用初始化语法:MyClass a = b。

n对象放置在封闭括号的初始化列表中。

n对象由一个异常抛出或捕获。

q赋值运算符:赋值运算符是用来把一个对象的值赋值给另一个对象,例如:a = b。它不同于拷贝构造函数的是因为已经存在的赋值对象是已经存在的。下面给出几条实现赋值复制运算符的指导方针:

n右边的操作数使用常量引用。

n返回*this作为引用来允许运算符链接operator chaining

n在设置新的状态之前清除任何现有的状态。

n通过比较this和&rhs来检测自我赋值(a = a)。

从上面的这些要点可以得出推论:如果你要创建一个不让用户拷贝的对象(也叫引用对象),那么你应该把拷贝构造函数和赋值运算符声明成私有的,或者使用boost::noncopyable。

很多C++开发新手会陷入麻烦,因为他们有个分配资源的类,因此需要一个析构函数,但是他们又没有同时定义一个拷贝构造函数和赋值运算符。例如,看看下面这个简单的整型数组类,我通过内联的方式来实现构造函数和析构函数,这样让行为更清晰。

[代码 P176 第一段]

class Array

{

public:

explicit Array(int size) :

mSize(size),

mData(new int[size])

{

}

~Array()

{

delete [] mData;

};

int Get(int index) const;

void Set(int index, int value);

int GetSize() const;

private:

int mSize;

int* mData;

};

 

这个类分配了内存,但是没有定义拷贝构造函数和赋值运算符。因此,当两个变量超出范围后,下面的代码就会崩溃,因为每个析构函数都会试图释放相同的内存。

[代码 P176 第二段]

{

Array x(100);

Array y = x; // y now shares the same mData pointer as x

}

当创建一个值对象时,你必须遵循“大三”(The Big Three)原则。这个术语是由Marshall Cline在上个世纪九十年代发明的,实质上是讲述有三个成员函数总是结伴在一起:析构函数、拷贝构造函数和赋值运算符(Cline等,1998)。如果你定义了其中的一个,那么通常也需要定义另外其它的两个(声明一个空的虚析构函数来允许子类继承是一个例外,因为它实际上并没有执行析构操作)。James Coplien把这个相同的概念称作传统的标准类形式(Coplien, 1991)。

提示

如果类有分配资源,你应该遵循“大三”原则:定义一个析构函数、拷贝构造函数和赋值运算符。

6.2.1 控制编译器生成函数

在C++98规范中,你无法控制编译器自动生成这些特殊函数的行为。例如,先前已经讨论过:如果你没有声明一个拷贝构造函数,那么编译器总是会自动生成一个。不过,在新的C++0x规范中,你可以显式控制编译器是否自动生成这些函数。例如,下面的例子特别地指定编译器创建一个私有默认构造函数和虚析构函数,这两个函数都是使用编译器生成的版本:

[代码 P177 第一段]

class MyClass

{

public:

virtual~MyClass() =default;

private:

MyClass() =default;

};

你也可以指定编译器禁止生成指定的函数,否则的话就会自动生成。例如,这种方式也可以让一个类成为不可复制的,这种技术在前面讨论过,通过声明一个私有拷贝构造函数和赋值运算符实现。

[代码 P177 第二段]

class NonCopyable

{

public:

NonCopyable()= default;

NonCopyable(const NonCopyable&)= delete;

NonCopyable & operator=(const NonCopyable&)= delete;

};

当然,这些都是C++0x才有的特性。不过,一些编译器也对这个功能提供了实验性的支持,如:GNU C++ 4.4编译器。

6.2.2 定义构造函数和赋值

因为编写构造函数和运算符是一个比较困难的事情,所以这里给出一个带有几种组合的例子。它构建于上述的一个数组例子之上,给出一个用来存储字符串数组的类。因为数组是动态分配的,所以你必须定义一个拷贝构造函数和赋值运算符,否则你一旦复制数组的话,就会在析构时释放两次内存两次。这里给出Array类在头文件中的声明:

[代码 P177 第三段]

#include <string >

class Array

{

public:

// default constructor

Array();

// non-default constructor

explicit Array(int size);

// destructor

~Array();

// copy constructor

Array(const Array &in_array);

// assignment operator

Array &operator = (const Array &in_array);

std::string Get(int index) const;

bool Set(int index, const std::string &str);

int GetSize() const;

private:

int mSize;

std::string *mArray;

};

接下来是构造函数和赋值运算符的样例声明:

[代码 P178 第二段]

#include "array.h"

#include <iostream >

#include <algorithm>

// default constructor

Array::Array() :

mSize(0),

mArray(NULL)

{

}

// non-default constructor

Array::Array(int size) :

mSize(size),

mArray(new std::string[size])

{

}

// destructor

Array::~Array()

{

delete [] mArray;

}

// copy constructor

Array::Array(const Array &in_array) :

mSize(in_array.mSize),

mArray(new std::string[in_array.mSize])

{

std::copy(in_array.mArray, in_array.mArray+mSize, mArray);

}

// assignment operator

Array &Array::operator =(const Array &in_array)

{

if (this !=&in_array) // check for self assignment

{

delete [] mArray; // delete current array first

mSize=in_array.mSize;

mArray =new std::string[in_array.mSize];

std::copy(in_array.mArray, in_array.mArray+mSize, mArray);

}

return *this;

}

有了上述的Array类,下面的代码演示的是各种方法调用时的情形:

[代码 P179 第二段]

will be calle d.

Array a; // default constructor

Array a(10); // non-default constructor

Array b(a); // copy constructor

Array c = a; // copy constructor (because c does not exist yet)

= c; // assignment operator

请注意:在某些情况下,编译器会忽略调用拷贝构造函数。例如:如果执行某种形式的返回值优化(Return Value Optimization)(Meyers, 1998)。

6.2.3 Explicit关键字

你或许已经注意到:在刚刚提过的Array例子中的非默认构造函数的声明,里面有使用到explicit关键字。给任何只接受单个参数的构造函数添加explicit是一种不错的常见操作。它是用来当构造一个对象时阻止隐含地调用特定的构造函数。例如,没有使用explicit关键字的话,下面的代码在C++中就是有效的:

[代码 P179 第三段]

Array a = 10;

这将使用整型参数10来调用Array的单参数构造函数。不过,这种隐含的行为类型可能会造成混淆、不直观。在大多数情况下,不是想要的。再给一个这种非预期隐含转换的例子,考虑下面的函数签名:

[代码 P179 第四段]

void CheckArraySize(const Array &array, int size);

如果没有把Array的单参数构造函数声明成explicit,那么你可以这样调用函数:

[代码 P179 第五段]

CheckArraySize(10, 10);

因为编译器不会强制第一个参数是Array对象,所以这降低了API的类型安全。这样的话,用户容易忘记参数的正确顺序并会传入错误顺序的参数。这就是为什么你应该总是把单参数构造函数声明成explicit,除非你知道你要支持隐含转换。

你也可以把拷贝构造函数声明成explicit的。这会阻止拷贝构造函数的隐式调用。不过,你也可以通过使用“Array a = b”或“Array a(b)”语法来显式地调用拷贝构造函数。

提示

在声明任何单参数构造函数前,考虑使用explicit关键字。

说明一下,新的C++0x规范允许你把explicit关键字使用在转换运算符(conversion operators)之前,或者构造函数前。这么做可以阻止那些转换函数被用于隐式转换。

6.3 常量正确性const correctness

常量正确性const correctnessConst correctness是指:在C++中使用const关键字把一个变量或方法声明成不可变的。这是一种编译时构造,用来保证代码不会修改某个变量。在C++中,你可以用const声明变量,这表示它们不应该被修改;你也可以用const声明方法,这表示你不应该修改类的任何成员变量。使用常量正确性const correctness是一种简单且良好的编程实践。然而,它也提供了文档来表达方法的意图,从而能够更加容易地使用方法。

提示

确保API的常量正确性const correctness

6.3.1 方法常量正确性const correctness

常量方法不能修改类中的任何成员变量。从本质上说就是:把常量方法中的所有成员变量都看做是常量变量。这种形式的常量正确性const correctness是通过在方法参数列表后添加const关键字来标明的。声明一个常量方法主要有两个好处:

(1).明确指出该方法不会改变对象的状态。前面刚刚提到过,这也可以看成是对API用户有用的文档。

(2).允许方法当成对象的常量版本来使用。一个非常量方法无法被对象的常量版本所调用。

Scott Meyers介绍了常量方法所代表的两个阵营的观点。按位常量(bitwise constness)阵营认为常量方法不应该修改类的任何成员变量;还有一方是逻辑常量(logical constness)阵营认为如果在用户无法察觉的情况下,常量方法可以修改成员变量(Meyers, 2005)。你的C++编译器遵循按位方法。不过,有时候你确实需要采用逻辑常量的风格。有个经典的例子就是你要缓存类的一些属性,因为它计算需要消耗很长时间的计算。例如,考虑一个HashTable(哈希表)类,需要高效率地返回哈希表中元素的个数。因此,你决定缓存它的大小,根据需求再慢慢地计算这个值。下面给出这个类的声明:

[代码 P181 第一段]

Class HashTable

{

public:

void Insert(const std::string &str);

int Remove(const std::string &str);

bool Has(const std::string &str) const;

int GetSize() const;

...

private:

int mCachedSize;

bool mSizeIsDirty;

...

};

你可以这样实现Get Size()常量方法,如下所示:

[代码 P181 第二段]

int HashTable::GetSize() const

{

if (mSizeIsDirty)

{

mCachedSize = CalculateSize();

mSizeIsDirty= false;

}

return mCachedSize;

}

不幸的是:这不是合法的C++代码,因为GetSize()方法实际上修改了成员变量(mSizeIsDirty和mCachedSize)。不过,这些不是公共接口的一部分:它们是内部状态,为我们提供了一个更高效的API。这就是为什么会有逻辑常量这个概念的原因。C++通过mutable(可变的)关键字提供了一种解决这个问题的方法。把mCachedSize和mSizeIsDirty变量声明成可变的状态就可以在常量方法中进行修改了。使用mutable就可以很好地实现API的逻辑常量,而不用移除成员函数上的const关键字,真正地声明为常量方法。

[代码 P181 第三段]

Class HashTable

{

public:

...

private:

mutable int mCachedSize;

mutable bool mSizeIsDirty;

...

};

提示

只要可以就声明为const(常量)方法和参数。在日后试着改进常量正确性const correctness将是一个耗时和令人沮丧的差事。

6.3.2 参数常量正确性const correctness

使用const关键字也可以用来指定一个参数是输入还是输出参数,也就是说,一个参数是用来把值传入方法还是用来接收某个结果的。例如,看看下面的方法:

[代码 P182 第一段]

std::string StringToLower(std::string &str);

这种方式的函数签名是否允许这个方法修改字符串是不明确的。明确的是它返回一个字符串结果,但是或许它也会改变参数字符串。如果它想这么做的话也确实可以这么做。如果方法的目的是接收参数并返回小写的版本,而同时不影响输入的字符串,那么只要简单地添加一个const就可以变得十分明朗了。

[代码 P182 第二段]

std::string StringToLower(const std::string &str);

现在,编译器会强制StringToLower()函数不会修改用户传入的字符串值。因此,只要查看函数的签名就可以清楚地明白这个函数的使用目的。

你常常会发现:如果使用了一个常量方法,那么任何引用或指针参数也都可以声明成常量。不过,这并不是一个硬性规定,它只是从逻辑上遵循常量方法不会修改任何状态的一般性承诺。例如,下面函数的root_node参数能够声明成常量,那是因为它不需要为了计算常量方法的结果而修改这个对象。

[代码 P182 第三段]

bool Node::IsVisible(const Node &root_node) const;

提示

当向常量方法传入一个引用或指针时,要考虑一下参数是否也可以声明成常量。

6.3.3 返回值常量正确性const correctness

当返回一个函数的结果时,把这个结果声明成常量的最主要原因是它引用了一个对象的内部状态。例如,如果你正在返回一个值类型的结果,那么把它设置成常量并没有什么意义,因为返回的对象只是一个副本,因此修改它的话不会对类的内部状态有任何影响。

或者,如果你返回一个私有数据成员的指针或引用,那么你应该把结果声明成常量,否则用户就能在不通过公共API的情况下修改内部状态。在这种情况下,你必须考虑返回的指针或引用是否比类的生存期更长。如果可能的话,你应该考虑返回一个引用计数指针,如:boost::shared_ptr,这在第二章中讨论过。

因此,你做的最常见的决定就是:返回值的常量正确性const correctness是通过值类型返回还是常量引用来返回,也就是说:

[代码 P183 第一段]

// return by value

std::string GetName() const

{

return mName;

}

// return by const reference

const std::string &GetName() const

{

return mName;

}

通常,我推荐你返回的是值类型,因为这样比较安全。不过,在对性能要求高的一些场合,你可能更喜欢常量引用方法。返回值类型比较安全是因为你不用当心用户在对象销毁后会操作引用,还有一个原因就是返回常量引用会破坏封装性。

提示

首选偏好在函数中返回值类型的结构,胜过返回常量引用。

从表面上看,我们前面给定的常量引用GetName()方法看起来是可接受的:方法使用常量声明,这表明它不会修改对象的状态,返回的对象内部状态的引用也声明成常量,这样用户也不能修改它。然而,还是有果敢的用户总是能够摆脱引用常量,接着就可以直接修改底层的私有数据成员,请看下面的例子:

[代码 P183 第二段]

// get a const reference to an internal string

const std::string &const_name = object.GetName();

// cast away the constness

std::string &name = const_cast<std::string &>(const_name);

// and modify the object’s internal data!

name.clear();

6.4 模板

模板在编译时提供了一种通用和强大的生成代码的能力。它们特别是用来生成那些相似而只有类型有区别的大量代码。然而,如果你打算为公共API提供一个类模板,那么将有几个问题你应该考虑到,来确保你能够提供一个良好隔离的、有效的和跨平台的接口。下面的几个部分将解决这些问题。

要注意的是:我不会涵盖模板编程的方方面面,只介绍那些对API设计有重要影响的属性。如果你要更全面和深入地了解模板的话,那么市面上已经有几本不错的书(Alexandrescu,2001;Josuttis,1999;Vandevoorde and Josuttis,2002)。

6.4.1 模板术语

模板往往是C++规范中不易理解的部分,因此我们先定义一些术语,以便我们掌握基础后再继续讨论。我将使用下面的模板声明做为模板定义的参考:

[代码 P184 第一段]

template <typename T >

class Stack

{

public:

void Push(T val);

T Pop();

bool IsEmpty() const;

private:

std::vector <T> mStack;

};

这个类模板描述了一个泛型堆栈类,你可以指定堆栈中元素的类型T。

q模板参数(Template Parameters):这些名称是罗列在模板声明的template关键字后面。例如,T就是一个前面给出的Stack例子的单模板参数。

q模板限定参数(Template Arguments):这些实体是用限定的类型代替模板参数。例如,给定一个限定Stack<int>,“int”就是模板的限定参数。

q实例化(Instantiation):这是由编译器通过一个具体的类型来取代每个模板参数,生成一个规则的类、方法或函数。当你创建一个基于模板的对象时,这会悄悄地发生。或者,如果你要明确控制代码生成这些时发生。例如,下面的代码行创建了两个特定的堆栈实例并促使编译器为这两个不同的类型生成代码。

[代码 P184 第二段]

Stack<int > myIntStack;

Stack<std::string > myStringStack;

q隐式实例化(Implicit Instantiation):这是编译时决定何时生成模板实例代码。由编译器决定,这意味着它必须找到一个合适的地方来插入代码,而且还必须确保代码只有一个实例存在,以避免重复的符号链接错误。这是一个要解决的重要问题,会导致对象文件更加的膨胀或增加编译和链接时间。对API设计更重要的是:隐式实例化意味着,你不得不在头文件中包含模板定义,以便编译器在每当它需要生成实例化代码时访问定义。

q显示实例化(Explicit Instantiation):这是由程序员决定的,编译器何时生成一个特定规范的代码。这在编译和链接时间上是更为高效的,因为编译器不再需要持有所有隐式实例化的登记信息。不过,这就把负担转移到程序员身上,要确保那个特定规范实例化一次,且只有一次。对API来说,显示显式实例化允许我们把模板实现移入到.cpp文件中,从而实现对用户的隐藏。

q推迟实例化(Lazy Instantiation):这描述的是C++编译器的标准隐式实例化行为,它只在实际要用到时才为模板部分生成代码。例如,前面的两个例子,如果你从来不会调用myStringStack对象的IsEmpty(),那么编译器就不会为方法的std::string特定的规范生成任何代码。这意味着你可以用一些类型实例化模板,但不是类模板的全部方法。例如,假设有个方法使用>=运算符,但是你要实例化的类型没有定义这个类型。只要你不调用这个试图使用>=运算符的特殊方法,那就不会有什么问题。

q特殊化(Specialization):当一个目标被实例化后,所生成的类、方法或函数叫做特殊化。更具体点说,这是一种实例化后(或生成的)的特殊化。不过,术语特殊化也可以用在你通过给所有的模板参数指定具体的类型来提供一个函数的自定义实现。我在API设计风格章节中给出过一个例子,下面是Stack::Push()方法的实现,采用整型类型来进行特殊化。这叫做显式特殊化。

[代码 P185 第一段]

template <>

void Stack <int >::Push(int val)

{

// integer specific push implementation

}

q部分特殊化(Partial Specialization):这是你所提供的在所有可能的情况下的一个子集模板的特殊化。也就是说,你指定模板的某个属性,但是仍然允许用户指定其它属性。例如,如果有个目标接受多个参数,你只为其中的一个参数指定具体的类型。在我们的带有单模板参数的Stack例子中,你可以部分特殊化这个目标,处理为任何类型T的指针。这仍然让用户可以创建任何堆栈类型,不过这也要你编写特定的逻辑来处理这种用户创建的指针堆栈的情况。下面请看看这种部分特殊化类声明:

[代码 P185 第二段]

template <typename T >

class Stack<T*>

{

public:

void Push( T *val);

T*Pop();

bool IsEmpty() const;

private:

std::vector <T*> mStack;

};

6.4.2 API设计隐式实例化

如果你允许用户使用他们自己的类型来实例化类模板,那么你就需要使用隐式模板实例化。例如,如果你提供了一个智能指针类模板,smart_pointer<T>。你无法提前知道用户采用什么类型来实例化。因此,编译器需要在使用到它时能够访问模板的定义。这从本质来说就是你必须在头文件中暴露模板的定义。就API设计的健壮性而言,这是隐式实例化的最大缺点。不过,即使在这种情况下没有必要隐藏细节,你还是要尽力做好隔离它们的工作。

假定你需要在头文件中包含模板定义,这很容易实现,只要直接把定义内联到类定义中即可。我已经把这种设计归类为糟糕的设计,这种归类在模板中也不例外。我建议把所有的模板实现细节都整合到一个独立的实现头文件中去,接着再包含到主要公共头文件中。这里使用Stack类模板来做为例子,你可以提供这样子的主要公共头文件:

[代码 P186 第一段]

// stack.h

#ifndef STACK_H

#define STACK_H

#include <vector >

template <typename T >

class Stack

{

public:

void Push(T val);

T Pop();

bool IsEmpty() const;

private:

std::vector <T> mStack;

};

// isolate all implementation details within a separate header

#include "stack_priv.h"

#endif

接着是实现头文件,stack_priv.h,请看下面:

[代码 P186 第二段]

// stack_priv.h

#ifndef STACK_PRIV_H

#define STACK_PRIV_H

template <typename T >

void Stack <T>::Push(T val)

{

mStack.push_back(val);

}

template <typename T >

T Stack <T >::Pop()

{

if (IsEmpty())

return T();

T val = mStack.back();

mStack.pop_back();

return val;

}

template <typename T >

bool Stack <T >::IsEmpty() const

{

return mStack.empty();

}

#endif

这种技术被很多高质量的基于模板的API所采用,如各种Boost头文件。它的好处就是保持了主要公共头文件的整洁,把内部细节隔离到一个独立的、能够清晰地包含私有细节头文件中。(同样的技术也可以用来隔离内联函数的细节和它们的声明。)

在头文件中包含模板定义的技术被叫做包含模式(Inclusion model, Vandevoorde 和Josuttis, 2002)。值得注意的是,还有一种可选的风格较做分离模式(Separation Model)。这允许在一个.h文件中使用export关键字声明类模板。那么,模板方法的实现可以放在.cpp文件中。从API设计的观点来看,这是一个更好的模式,因为它允许我们从公共头文件处移除所有的实现细节。特别地,GNU C++ 4.3和微软的Visual C++ 9.0编译器都不支持export关键字。因此,你应该在API中避免使用这种技术,来最大化API的可移植性。

6.4.3 API设计显示实例化

如果你想为API设计一个预定的模板特殊化集,不允许用户进一步创建,那么你就要完全隐藏私有代码。例如,如果你已经创建了一个3D向量类模板:Vector3D<T>。你只想给这个模板提供这样的特殊化类型:int、short、float和double,而且你觉得没必要让用户创建更多的特殊化。

在这种情况下,你可以把模板定义放入到.cpp文件中,使用显式模板实例化来初始化那些你希望做为API的一部分导出的特殊化类型。template关键字可以用来创建一个显式实例化。例如,采用前面提过的Stack模板例子,你可以为int类型创建显式实例化,请看如下代码:

[代码 P187 第三段]

template class Stack< int >;

上面的代码会促使编译器生成int的特殊化代码。因此,它在接下来的代码中的其它地方不再尝试隐式实例化这个特殊化类型。最后结果是,使用显式实例化也可以帮助减少构建时间。

让我们看看为了利用这个特性,我们该如何组织代码。stack.h头文件和前面看到过的几乎一样,只是没有了#include "stack_priv.h" 行:

[代码 P188 第一段]

// stack.h

#ifndef STACK_H

#define STACK_H

#include <vector >

template <typename T >

class Stack

{

public:

void Push(T val);

T Pop();

bool IsEmpty() const;

private:

std::vector <T> mStack;

};

#endif

现在你可以为这个相关联的.cpp文件中的模板包含所有的实现细节:

[代码 P188 第二段]

// stack.cpp

#include "stack.h"

#include <string >

template <typename T >

void Stack <T>::Push(T val)

{

mStack.push_back(val);

}

template <typename T >

T Stack <T>::Pop()

{

if (IsEmpty())

return T();

T val = mStack.back();

mStack.pop_back();

return val;

}

template <typename T >

bool Stack <T >::IsEmpty() const

{

return mStack.empty();

}

// explicit template instantiations

template class Stack< int >;

template class Stack< double >;

template class Stack< std::string >;

这儿的最后三行比较重要,为int、double和std::string类型创建了Stack类模板的显式实例化。用户不能创建更多的特殊化类型(编译器也不会给用户创建隐式实例化),因为实现细节已经隐藏到.cpp文件中。不过,现在我们的实现细节已经成功隐藏到.cpp文件中了。

为了给用户表明他们可以使用哪种模板特殊化类型(例如,你为他们采取哪种类型的显式实例化),你可以在公共头文件的末尾添加一些类型定义,例如:

[代码 P189 第二段]

typedef Stack<int > IntStack;

typedef Stack<double > DoubleStack;

typedef Stack<std::string > StringStack;

采用这种模板风格是值得注意的:你(包括你的用户)都可以得到更快构建,因为已经移除了隐式初始化的开销,但是从头文件中也移除了模板定义,降低了API中的#include耦合和减少了用户程序每次必须编译的#include的API头文件的代码量。

提示

如果你只需要预先决定好的特殊化类型集,那么请首选偏好使用选用显式模板实例化。这么做可以隐藏私有细节并减少构建时间。

还值得注意的地方是:绝大多数编译器有提供一个完全关闭隐式实例化的选项,如果你只打算在代码中使用显式实例化的话,这将是一个有用的优化。在GNU C++和英特尔的ICC编译器中,这个选项叫做-fno-implicit-templates。

在C++的新版规范中,叫做C++0x,添加了对外部模板(extern templates)的支持。也就是你可以使用extern关键字来阻止编译器从当前的翻译单元(translation unit)实例化一个模板。实际上,当前已经有一些编译器支持这个特性了,如GNU C++编译器。通过添加外部模板,你就能够强制编译器在某个点实例化一个模板并通知它在其它点不进行实例化。例如:

[代码 P189 第三段]

// explicitly instantiate the template here

template class Stack< int >;

// do not instantiate the template here

extern template class Stack<int >;

6.5 运算符重载

除了函数重载,C++也允许你重载许多类的运算符,如+、*=或[]。这是用来让类的行为看起来更像内建类型,也为每个方法提供了更为紧凑和直观的语法。例如,可以替代下面的语法:

[代码 P190 第一段]

add(add(mul(a,b), mul(c,d)), mul(a,c))

你可以编写支持下面语法的类:

[代码 P190 第二段]

a*b + c*d + a*c

当然,你应该只在有意义的情况下使用运算符重载。也就是说,这么做对于API用户来说是比较自然的,并不会觉得比较奇怪。这通常意味着你应该为运算符保留其自然的语义,如使用+运算符来执行和相加与连接相似的操作。你也应该避免重载运算符&&、||、&(and的符号)和,(逗号),因为这些行为会让用户感到比较奇怪,如绕过赋值(short-circuited evaluation)和未定义的赋值顺序(Meyers 1998; Sutter 和 Alexandrescu, 2004)。

在本章的前面提过,如果没有显式定义的话,C++编译器会为类自动生成一个默认的赋值运算符(=)。不过,如果你希望给对象使用任何其它的运算符,那么你必须显式定义它们,否则会因为链接错误而终止。

6.5.1 可重载的运算符

C++中的有些运算符是不可以重载的,如.(英文句点)、*(星号)、?(问号)和::,还有预处理符号#和##,还有sizeof运算符。剩下的运算符都可以在类中重载,主要可以分成两个分类:

(1).一元运算符:这些运算符作用于单个操作数。一元运算符的列表包括:

[图 P190 第一张]

(2).二元运算符:这些运算符作用于两个操作数。二元运算符的列表包括:

[图 P191 第一张]

6.5.2 自由运算符和成员运算符

运算符可以定义为类成员,也可以定义成自由函数。有些运算符要定义成类成员,但是其它的都可以定义。例如,下面的代码演示的是+=运算符定义成类成员:

[代码 P191 第一段]

class Currency

{

public:

explicit Currency(unsigned int value);

// method form of operator+=

Currency &operator +=(const Currency &other);

unsigned int GetValue() const;

...

};

下面的代码是相等的API使用自由函数的运算符版本:

[代码 P191 第二段]

class Currency

{

public:

explicit Currency(unsigned int value);

unsigned int GetValue() const;

.. .

};

// free function form of operator+=

Currency &operator +=(Currency &lhs, const Currency &rhs);

本节包括让运算符成为自由函数还是方法的一些最佳做法。

首先,C++标准要求:下面的运算符被声明为成员方法,以确保它们能够得到一个左值(lvalue,指向一个对象的表达式)做为它们的第一个操作数:

[代码 P192 第二段]

=Assignme nt

[] Subscr ipt

- -> Class membe r acce ss

-  ->* Po inter-to-m ember selection

() Funct ion cal l

(T) Con version, i.e., C-st yle cast

- new /delete

剩下的可重载的运算符既可以定义成自由函数,也可以定义成类方法。从API设计的观点来看,我建议你选用自由函数来定义运算符。这主要是有两个原因:

(1).运算符对称性(Operator symmetry):如果一个二元运算符定义成类方法,那么必须有个对象用作左边操作数。举个*运算符的例子,这意味着用户能够编写这样的表达式“currency * 2”(假设你已经定义了非显式构造函数或一个int类型的特定具体*运算符),但不是“2*currency”,因为2.operator*(currency)没有意义。这破坏了用户期望的运算符可交换的属性。也就是说,x * y应该和y * x是一样的。请注意:把*运算符声明为自由函数,如果你没有显式声明构造函数,那么这使允许你为左右运算符可以从隐式类型转换中获益。

(2).降低耦合:自由函数不能访问类的私有细节。因此它和类就没有那么耦合,因为它只能访问公共方法。这是第二章讲过的普通的API设计总结语句:把无法访问私有的或受保护的类方法变成自由函数可以降低API的耦合度(Meyers,2000; Tulach, 2008)。

前面提到过优先使用自由函数运算符,现在我讲讲例外的情况:如果运算符必须访问类的私有或受保护的成员,那么你应该把运算符定义成类的一个方法。我提出这个例外是因为不这么做的话你就得把自由运算符声明成类的友元。给类添加一个友元将是一场恶梦,我将在本章的稍后讨论。我在这里提这些的一个特殊原因是用户不能修改你的类的友谊列表(friendship list),因为他们不能用同样的方式来添加新的运算符。

提示

优先选择把运算符声明成自由函数,除非运算符必须访问私有或受保护的数据成员或运算符是下列符号:=、[]、->、()、(T)、new或delete。

6.5.3 向类添加运算符

让我们进一步设计Currency(货币)类,具体化上面提到过的要点。+=运算符修改了对象的内容,因为我们知道所有的成员变量都应该是私有的,你很可能喜欢把+=运算符设置成一个成员变量。然而,+运算符不会修改左边的操作数。因此,它不需要访问私有成员,可以定义为自由函数。正如前面提到过,你也需要把它定义成自由函数来确保它受益于对称行为(symmetry behavior)。实际上,+运算符可以由+=运算符实现,这样就可以重用代码和提供更加一致的行为。这也减少了需要从派生类重载的方法数量。

[代码 P193 第一段]

Currency operator +(const Currency &lhs, const Currency &rhs)

{

return Currency(lhs) += rhs;

}

很明显,这种技术可以同样地应用在其它算术运算符上。例如:-、-=、*、*=、/和/=。举个例子:*=可由一个成员函数实现,而*可以由一个使用*=运算符的自由函数实现。

至于相关的运算符:==、!=、<、<=、>和>=,这些也要通过自由函数来实现,以确保对称行为。在这个Currency类例子中,你可以通过使用公共GetValue()方法来实现它们。然而,如果这些运算符需要访问对象的私有状态,有一个方法可以解决这个明显的问题。在这种情况下,可以提供一个公共方法来测试相等和小等于条件,如IsEqualTo()和and IsLessThan()。那么现在所有的相关运算符都可以由下面的两个基础函数实现(Astrachan, 2000)。

[代码 P193 第二段]

bool operator ==(const Currency &lhs, const Currency &rhs)

{

return lhs.IsEqualTo(rhs);

}

bool operator !=(const Currency &lhs, const Currency &rhs)

{

return ! (lhs == rhs);

}

bool operator <(const Currency &lhs, const Currency &rhs)

{

return lhs.IsLessThan(rhs);

}

bool operator <=(const Currency &lhs, const Currency &rhs)

{

return ! (lhs > rhs);

}

bool operator >(const Currency &lhs, const Currency &rhs)

{

return rhs < lhs;

}

bool operator >=(const Currency &lhs, const Currency &rhs)

{

return rhs <= lhs;

}

这里我要考虑的最后一个运算符是<<,我用来做流输出(stream output)(与之对照的是位移 bit shifting)。流运算符应该声明成自由函数,因为第一个参数是一个流对象。你可以使用公共GetValue()方法来实现。然而,如果流运算符确实需要访问类的私有成员,那么你可以给<<运算符创建一个公共的ToString()方法调用,以避免使用友元。

把这些建议全部整合起来,下面看看Currency的运算符:

[代码 P194 第二段]

#include <iostream >

class Currency

{

public:

explicit Currency(unsigned int value);

Currency::Currency();

Currency(const Currency &obj);

Currency &operator =(const Currency &rhs);

Currency &operator +=(const Currency &rhs);

Currency &operator -=(const Currency &rhs);

Currency &operator *=(const Currency &rhs);

Currency &operator /=(const Currency &rhs);

unsigned in GetReal() const;

private:

class Impl;

Impl *mImpl;

};

Currency operator +(const Currency &lhs, const Currency &rhs);

Currency operator -(const Currency &lhs, const Currency &rhs);

Currency operator *(const Currency &lhs, const Currency &rhs);

Currency operator /(const Currency &lhs, const Currency &rhs);

bool operator ==(const Currency &lhs, const Currency &rhs);

bool operator !=(const Currency &lhs, const Currency &rhs);

bool operator <(const Currency &lhs, const Currency &rhs);

bool operator >(const Currency &lhs, const Currency &rhs);

bool operator <=(const Currency &lhs, const Currency &rhs);

bool operator >=(const Currency &lhs, const Currency &rhs);

std::ostream& operator <<(std::ostream &os, const Currency &obj);

std::istream& operator >>(std::istream &is, Currency &obj);

6.5.4 运算符语法

表格6.1提供(i)了在类中允许重载的运算符和(ii)声明每个运算符所建议的语法,以便它们和内建的相关者有相同的语义。表格6.1省略了不能重载的运算符,就是前面我们提到过的不应该重载的,如&&和||。一个运算符能够定义成自由函数或类方法,我给出了两种形式,但是我优先列出了你应该首选的自由函数的形式,除非运算符需要访问私有或受保护的成员。

[表格 6.1 P195-P197]

[表格 6.1 在API中声明的这些运算符和语法的列表]

6.5.5 转换运算符

转换运算符(conversion operator)为你提供了这样一种方式:定义了如何让一个对象自动转换成一个不同的类型。一个典型的例子就是定义一个自定义字符串类,可以传递给接受const char *指针的函数,如标准C库函数strcmp()或strlen()。

[代码 P197 第一段]

class MyString

{

public:

MyString(const char *string);

// convert MyString to a C-style string

operator const char *() { return mBuffer; }

private:

char *mBuffer;

int mLength;

};

// MyString objects get automatically converted to const char *

MyString mystr("Haggis");

int same = strcmp(mystr, "Edible");

int len = strlen(mystr);

要注意的是转换运算符并没有指定返回值的类型。那是因为该类型是由编译器根据运算符的名称来推断出来的。还要注意的是转换运算符不带任何参数。在C++0x草案标准中,可以在转换运算符前加上explicit关键字来阻止它使用隐式转换。

提示

向类中添加转换运算符来进行自动类型转换。

6.6 函数参数

下面的部分着手解决几个关于C++函数参数的最佳用法。这包括你该何时使用指针,而不是使用引用来把对象传入一个函数,还有何时使用默认参数。

6.6.1 指针和引用参数

当为函数指定参数时,你可以选择通过值参数、指针或引用来传递它们。例如:

[代码 P198 第二段]

bool GetColor(int r, int g, int b); // pass by value

bool GetColor(int &r, int &g, int &b); // pass by reference

bool GetColor(int *r, int *g, int *b); // pass by pointer

当你要得到一个实际的对象句柄,而不是对象的拷贝时,你会通过引用或指针来传递一个参数。这有出于性能的原因(这会在第七章中讨论),或是你能够修改用户的对象。C++编译器在后台也是使用指针来实现引用的,其实它们是一回事。不过,它们还是有所差别的:

q可以像值类型一样使用引用,例如:使用object.Function()来代替object->Function()。

q引用必须初始化为指向一个对象并在初始化后不支持修改所指向的对象。

q你无法像获取指针的地址一样获取引用的地址。使用&运算符在引用上返回的是所指向对象的地址。

q你无法创建引用数组。

参数要使用指针还是引用这完全取决于个人喜好。不过,我建议在通常情况下你应该给任何输入参数优先使用引用。这是因为对用户来说,调用语法更简单并且你不需要去检测NULL值(因为引用不可能是NULL)。然而,如果你需要支持传递NULL或者你正在编写纯C API,那么很明显:你必须使用指针。

对于输出参数来说(函数可以修改参数),一些工程师不喜欢使用引用,因为它不能指示用户参数是可以修改的。例如,引用和指针版本的GetColor()函数可以这样被客户调用:

[代码 P199 第一段]

object.GetColor(red, green, blue); // pass by reference

object.GetColor(&red, &green, &blue); // pass by pointer

在这些例子中,GetColor()函数可以修改red、green和blue变量的值。不过,指针版本很容易看出上述情况,因为需要使用&运算符的使用。因此,像Qt框架这样的API都偏向使用指针代替引用来表示输出参数。如果你也决定遵循这个规范,那么隐含的意思是所有的引用参数都应该是常量引用。

提示

如果可行的话:对于输入参数,首选偏向使用常量引用来代替指针。对于输出参数,考虑使用指针代替非常量引用,这样可以清晰地指示用户参数是可以修改的。

6.6.2 默认参数

默认参数是一个很有用的工具,它可以减少API中方法的数量并相当于它们是如何使用的隐含文档。它们也可以用来通过向后兼容来扩展API调用,这样旧的代码仍然可以编译,但是新的代码可以可选地提供额外的参数(应该注意到这会破坏二进制兼容性,被破坏的方法符号名将需要修改)。例如,考虑下面的Circle类的代码片段:

[代码 P199 第二段]

class Circle

{

public:

Circle(double x =0, double y=0, double radius=10.0);

...

};

在这种情况下,用户可以使用很多不同的方式来构造新的Circle对象,根据需要提供更多的细节。例如:

[代码 P199 第三段]

Circle c1();

Circle c2(2.3);

Circle c3(2.3, 5.6);

Circle c4(2.3, 5.6, 1.5);

然而,这个例子有两个需要知道的问题。首先,它支持没有逻辑意义的参数组合,如提供一个x参数,而没有y参数。还有,默认值会编译进用户的程序中。如果你发布了一个带有不同默认半径的新版本API,这意味着用户必须重新编译他们的代码。这从本质上来说,当你没有显式指定一个半径值,你就暴露了API的行为。

为了演示这个为什么是不好的,考虑下面的可能性:稍后你要添加对不同默认单位的支持,让用户可以用米、厘米或毫米来指定。在这种情况下,常数默认半径10.0就不适合所有的单位了。

一种可选的方式是提供多个重载方法来代替使用默认参数,例如:

[代码 P200 第一段]

class Circle

{

public:

Circle();

Circle(double x, double y);

Circle(double x, double y, double radius);

.. .

};

使用这种方式,前两个构造函数可以使用默认值。但是更重要的是,这些默认值是在.cpp文件中指定的,并没有在.h文件中暴露出来。因此,稍后版本的API可以修改这些值而不会影响公共接口。

提示

对于默认参数,当默认值有对外暴露实现常量时,优先偏好选择重载函数。

并不是所有的默认参数实例都需要转换成重载方法。特殊地,如果默认参数表示的是一个无效的或空值,如把NULL定义成指针的默认值或给字符串参数定义"",那么这种用法在API版本间不大可能发生更改。然而,如果你的API中有对将来发布后可能修改的特定常量值进行了硬编码,那么你应该把这些都使用重载方法技术来代替。

因为性能上的原因,你也应该尽力避免定义包含构造临时对象的默认参数,因为这些会通过值传递到方法中,造成昂贵的开销。

6.7 避免使用#define定义常量

#define预处理器指令在源码文件中基本上是用来让一个字符串代替另一个字符串。不过,在C++社区中给出许多好的理由来反对使用它(Cline等,1998; DeLoura, 2001; Meyers, 2005)。很多原因是关于使用它会发生诡异的问题,如果你使用#define指定代码宏,你希望在多个地方插入。例如:

[代码 P201 第一段]

#define SETUP_NOISE(i,b0,b1,r0,r1)\

=vec[i] + 0x1000;\

b0=(lltrunc(t)) & 0xff;\

b1=(b0 +1) & 0xff;\

r0=t - lltrunc(t);\

r1=r0 - 1.f;

然而,在公共API头文件中,你绝不应该通过这种方式来使用#define,因为这样泄露了实现细节。如果你要在.cpp文件中使用这种技术且理解#define的所有特质,那么你可以这么做,但是千万别在公共头文件中这么做。

还有就是API指定常量的#define的用法,例如:

[代码 P201 第二段]

#define MORPH_FADEIN_TIME 0.3f

#define MORPH_IN_TIME 1.1f

#define MORPH_FADEOUT_TIME 1.4f

你甚至也要避免#define的这种用法(当然,除非你是在编写一个纯C API),原因如下:

(1).没有类型检查:#define不包含对你定义的常量的任何类型检查。因此,你必须确保你显式指定的常量类型不会出现二义性,例如使用“f”前缀在单精度浮点型常量上。如果你定义了一个单精度常量,如简单见到的“10”,那么在某些情况下可能被认为是整型,会导致你所不希望的数学舍入错误。

(2).没有值域限制:#define语句是全局的,并没有限制在一个特定的值域范围,如单个类中。你可以使用#undef预处理器指令来取消先前的#define,但是这对于声明一个供用户使用的常量来说意义不大。

(3).没有访问控制:你不能把#define标记成public、protected或private。它本质上总是public。因此,你无法使用#define指定一个只能被派生类访问的常量(常量在基类中#define)。

(4).没有符号:对于前面给出的例子,符号名:MORPH_IN_TIME会被代码的预处理器剥离,这样会让编译器无法看到这个名称并且不能把它输入到符号表(Meyers, 2005)。当用户使用API并试图调试代码时,这隐藏了有价值的信息,因为他们使用的调试器只能看到简单的常量值,没有任何描述性名称。

优先偏好选用#define来声明API常量的用处是声明一个常量变量。我将在稍后的性能章节中讨论一些关于声明常量的最佳实践方式,声明常量变量可能让用户程序变得臃肿。现在,我只是简单地给出前面的#define例子的更好的转换版本,这些常量的实际值都指定到相关的.cpp文件中:

[代码 P202 第一段]

class Morph

{

public:

static const float FadeInTime;

static const float InTime;

static const float FadeOutTime;

.. .

};

如果你想让用户知道这些常量是什么值,那么你可以在API的文档中给出Morph类的相关信息。要注意到现在给出的这个版本就再也没有上面罗列过的那些问题:常量的类型是浮点型,值域是Morph类,显式标记了public并可以生成符号表条目。

 

提示

使用静态常量数据成员来表示类常量,代替#define的使用。

#define的更多用途是提供一个给定变量的可能值列表。例如:

[代码 P202 第一段]

#define LEFT_JUSTIFIED 0

#define RIGHT_JUSTIFIED 1

#define CENTER_JUSTIFIED 2

#define FULL_JUSTIFIED 3

更好的方式是通过enum关键字来表示这些枚举类型。使用枚举可以获得更好的类型安全,因为编译器可以确保你是使用符号名来设置枚举值,而不是直接使用一个整数(除非把整型显式转换成枚举类型)。这也不容易传递一个非法值,如前面的那个例子的-1或23。你可以把前面的#define代码行转换成下面的枚举类型:

[代码 P202 第二段]

enum JustificationType {

LEFT_JUSTIFIED,

RIGHT_JUSTIFIED,

CENTER_JUSTIFIED,

FULL_JUSTIFIED

};

6.8 避免使用友元

在C++中,友元可以让一个类拥有对另一个类或函数的完全访问权限。友元类或函数可以访问所有的受保护的和私有的类成员。这在你想把类拆分成两个或更多个且仍然需要每个部分能够访问其它部分的私有成员时是十分有用的。这还可以用在你需要使用一个内部访问器或回调技术。也就是说,在你的实现代码中的一些其它的内部类需要调用一个类中的私有方法。

一种可供选择的方式是暴露需要共享的数据成员和函数,把它们从私有的转换成公共的,这样其它类就可以访问它们了。不过,这意味着你把实现细节暴露给用户;这些细节是逻辑接口的一部分。从这个观点上来看,友元是不错的,因为它们让你可以把访问只开放给特定的用户。然而,友元会被用户滥用,允许他们对API类的内部细节进行完全访问。

例如,考虑下面的类:指定了单个Node(节点)是Graph(图)层级的一部分。Graph需要执行各种基于所有节点上的迭代,因此需要记录某个节点是否已经被访问过了(处理图循环)。有种实现这个的方式是让Node对象持有它是否被访问过的状态,用访问器来管理这个状态。因为这是一个纯粹的实现细节,你不要在公共接口中暴露这个功能。你把它声明成私有的,不过Graph对象通过把Node对象声明成一个友元来访问。

[代码 P203 第一段]

class Node

{

public:

...

friend class Graph;

private:

void ClearVisited();

void SetVisited();

bool IsVisited() const;

...

};

这表明看起来是没有问题的:你让各种*Visited()方法都是私有的并只允许Graph类能够访问内部细节。不过,这带来的问题是友元只是根据其它类的名称来提供功能。因此,有可能出现用户也创建一个他们自己的类叫Graph,这样也可以访问Node的所有受保护的和私有的数据(Lakos, 1996)。下面的用户程序演示了这种违反访问控制的方式实现起来是多么得容易:

[代码 P203 第二段]

#include "node.h"

// define your own Graph class

class Graph

{

public:

void ViolateAccess(Node *node)

{

// call a private method in Node

// because Graph is a friend of Node

node -> SetVisited();

}

};

.. .

Node node;

Graph local_graph;

local_graph.ViolateAccess(&node);

因此,使用友元会给API留下一个漏洞,可以用来避开公共的API边界和破坏封装性。

对于前面刚刚提到的例子,一种更好的解决方法是不去使用友元,让Graph对象去维护它已经访问过的节点的列表。例如,通过std::set<Node *>容器,而不是把访问状态存储在单独的节点本身的内部。这也是一种更好的概念设计,因为另一个类是否处理过Node的信息并不是Node本身的固有属性。

提示

避免使用友元。它们往往是糟糕的设计的标志且允许用户访问API所有的受保护的和私有的成员。

6.9 导出符号

除了语言级别的访问控制属性(public、private和protected),还有两个相关的概念允许你暴露物理文件级别的API符号。这些是:

(1).外部链接

(2).导出可见性

术语外部链接的意思是在一个翻译单元的符号可以从其它的翻译单元中访问,而导出是指来自一个库文件(如DLL)符号是可见的。只有外部链接符号是可以被导出的。

让我们先看看外部链接。这是第一个阶段决定用户是否能够访问你的共享库中的符号。特别地,.cpp文件中的全局(文件范围)自由函数和变量会有外部链接,除非你设法阻止这个。例如,考虑下面的可能出现在你的某个.cpp文件中的代码:

[代码 P204 第二段]

.. .

const int INTERNAL_CONSTANT = 42;

std::string Filename = "file.txt";

void FreeFunction()

{

std::cout << "Free function called" << std::endl;

}

.. .

即使你有在一个.cpp文件中使用这些函数和变量,一个资源丰富的用户能够很容易地从他们自己的程序中获取对这些符号的访问(这时候忽略符号导出问题)。接着他们能够在不通过公共API的情况下,直接调用你的全局函数和修改全局变量,这样就破坏了封装性。下面的代码演示了如何实现这个:

[代码 P205 第一段]

extern void FreeFunction();

extern const int INTERNAL_CONSTANT;

extern std::string Filename;

// call an internal function within your module

FreeFunction();

// access a constant defined within your module

std::cout << "Constant = " << INTERNAL_CONSTANT<< std::endl;

// change global state within your module

Filename = "different.txt";

要解决这种外部链接泄露问题有几种解决方法:

静态声明:预先用static关键字声明函数和变量。这种指定函数或变量应该有内部链接,因此它就不会在翻译单元外访问到。

匿名名空间:一种更符合C++语言习惯的解决方法是把文件范围的函数和变量限制在一个匿名名空间中。这是一种更好的方法,因为它避免了影响全局名空间。这可以这样实现,如下所示:

[代码 P205 第二段]

...

namespace {

const int INTERNAL_CONSTANT = 42;

std::string Filename = "file.txt";

void FreeFunction()

{

std::cout << "Free function called" << std::endl;

}

}

...

提示

使用内部链接来把文件范围的自由函数[l1]和变量隐藏到.cpp文件中。这意味着要使用static关键字或匿名名空间。

对于有外部链接的符号,还有一个关于导出符号的概念,就是决定共享库中的符号是否是可见的。绝大多数编译器都有为类和函数提供标示来让你可以显式指定一个符号是否出现在库文件的导出符号表中。然而,这趋向于编译器特定的行为,例如:

(3).微软的Visual Studio:DLL中的符号默认是不能访问的。你必须显式导出DLL中的函数、类和变量来允许用户访问它们。这个你可以通过在符号前使用__declspec(双下划线)修饰。例如,当你构建一个DLL时,你可以指定__declspec(dllexport)来导出一个符号。接着,用户必须指定__declspec(dllimport)来访问他们自己程序中的相同符号。

(4).GNU C++编译器:在一个动态库中的外部链接符号默认是可见的。不过,你可以使用可见性__attribute__修饰来显式地隐藏一个符号。做为一种可选的隐藏单独符号的方式,GNU C++ 4.0编译器引入-fvisibility=hidden标志来强制所有声明的可见性的默认值为隐藏。接着,单独的符号可以通过__attribute__ ((visibility("default")))来显式导出。这更像Windows的行为,所有的符号都认为是内部的,除非你显式地导出它们。使用-fvisibility=hidden标志也能大大提升动态库在装载时的性能,并可以让库文件更小。

在跨平台时,你可以定义各种预处理器宏来处理这些编译器的差异。这里有个例子定义了DLL_PUBLIC宏来显式导出符号,当使用GNU C++编译器时DLL_HIDDEN宏会隐藏符号。当你在Windows上构建库文件时,你要注意到你必须指定_EXPORTING定义,也就是说,/D "_EXPORTING"。这是一个任意的定义名:你可以随意调用(只要你也更新了如下代码)。

[代码 P206 第一段]

#if defined _WIN32 jj defined __CYGWIN__

#ifdef _EXPORTING // define this when generating DLL

#ifdef __GNUC__

#define DLL_PUBLIC __attribute__((dllexport))

#else

#define DLL_PUBLIC __declspec(dllexport)

#endif

#else

#ifdef __GNUC__

#define DLL_PUBLIC __attribute__((dllimport))

#else

#define DLL_PUBLIC __declspec(dllimport)

#endif

#endif

#define DLL_HIDDEN

#else

#if __GNUC__ > =4

#define DLL_PUBLIC __attribute__ ((visibility("default")))

#define DLL_HIDDEN __attribute__ ((visibility("hidden")))

#else

#define DLL_PUBLIC

#define DLL_HIDDEN

#endif

#endif

例如,为API导出一个类或函数,你可以这么做:

[代码 P207 第一段]

DLL_PUBLIC void MyFunction();

class DLL_PUBLIC MyClass;

许多编译器也允许你提供一个简单的ASCII文件来定义由动态库要导出的符号列表,这样你就不需要用诸如DLL_PUBLIC的宏来修饰代码。符号不会出现在文件中,也就对用户的程序隐藏起来了。例如,Windows Visual Studio编译器支持.def文件,而GNU编译器支持导出映射文件。可以参考附录A来获取这些导出文件的更多细节。

提示

从你的动态库中显式导出公共API符号来维持对类、函数和变量的直接访问。对于GNU C++,这等于隐含地使用-fvisibility=hidden选项。

6.10 编码规范

C++是一种拥有许多强大特性的复杂语言。使用好的编码规范可以帮助管理这种复杂性,确保所有的代码都遵循一定的风格规范,并避免常见的陷阱。它也有助于实现代码的一致性,这是我在第二章讲述过的一个良好的API所具有重要品质。

提示

为API指定编码标准,以协助实现一致性、定义流程和记载通用的工程陷阱。

制定一份编码标准文档是一个冗长且容易引起争议的过程,这不仅仅有语言复杂性的因素,也因为个人的品味和风格的不同。不同的工程师有不同的放置括号和空格的偏好,采用哪种风格的注释或函数是使用小写还是大写的驼峰格式(camelCase)命名。例如,本书中用到的指针或变量名(非类型名)旁边的引用符号,我都采用一致格式的源码片段,也就是用:

[代码 P207 第二段]

char *a, *b;

来代替:

[代码 P207 第一段]

char* a, *b;

我喜欢前者的风格,因为从语言角度来看,指针是和变量相关联的,而不是和类型(在这两个例子中,a和b都是指向char的类型指针)。然而,其它软件工程师更喜欢后者的风格。

在本书中,我并不主张你应该在项目中采用任何特殊的风格,但是我鼓励你在API设计中采用一些规范。重要的一点是要保持一致性。实际中,工程师普遍遵循这样的规则:当编辑一个源文件时,应该遵循文件中原有的规范,而不是添加你自己的风格,这种混合会生成一个不一致的风格(或者是你比较另类,要采用自己的风格来重新格式化整个文件)。

因为有很多大公司已经创建并发布了代码风格文档,你可以在做决定前采用一些这样的标准。你可以使用“C++ coding conventions”(C++ 编码规范)关键字搜索网络,你会得到很多有参考价值的提示。特别地,Google的C++风格指南是一份非常有价值的文档,被很多团体所使用(http://google-styleguide.googlecode.com/)。也有很多书提供更多关于代码构造的细节,深刻地阐释了哪些是应该遵循的,哪些是该避免的(Sutter and Alexandrescu, 2004)。

我并没有给出什么特别的建议,不过这里我罗列一些良好编码标准所涵盖的内容:

q命名约定:是使用.cc、.c++还是 .cpp;文件名大写;在文件名中使用前缀;类、函数、变量、区分私有成员、常量、类型定义、枚举、宏、名空间等大写;使用名空间等。

q头文件:如何#include头文件;给#include语句排序;使用#define guards(译者注:详见Google的风格指南http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#The__define_Guard;使用前置声明;内联代码策略等。

q注释:注释风格;注释文件、类、函数等的模板;文档需求;待办事项的注释代码或高亮已知的取巧代码等。

q格式化:每行长度的限制;空格对比Tab;括号的位置;语句间的间隔;如何分解很长的行;构造函数初始化列表的布局等。

q类:构造函数的使用;工厂;继承;多继承;接口;组合、结构对比类;访问控制等。

q最佳实践:模板的使用;异常的使用;枚举的使用;常量正确性const correctness;为输出参数使用指针;pimpl的使用;所有成员变量的初始化;转换;运算符重载规则;虚析构函数;全局的使用等。

q可移植性:编写指定架构的代码;平台的预处理器宏;类成员对齐(class member alignment)等。

q流程:编译时把警告当成错误;单元测试需求;代码检查的使用;用例的使用;SCM风格检测钩子(hook);使用额外的,诸如-Wextra和-Weffc++来进行编译。

 根据原书勘误 应为:file scope free function

Power by  YOZOSOFT
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值