c++高级编程学习笔记

小程序“hello world”

下面的代码可能是你所遇到的最简单 C++程序:

//helloworld.cpp
#include <iostream>
int main()
{
    std::cout<<"Hello world!"<<std::endl;
    return 0;
}

正如预期的那样,这段代码会在屏幕上输出“Hello,World!”这一信息。这是一个非常简单的程序,好像不会赢得任何赞誉,但是这个程序确实展现出与 C++程序格式相关的一些重要概念;

C++17 允许方便地使用嵌套的名称空间,即将一个名称空间放在另一个名称空间中。 在 C++17 之前,必须按如下方式使用嵌套的名称空间:

namespace MyLibraries
{
	namespace Networking
	{
		namespace FTP
		{
			
			/*.....*/
		}
	}
}

这在C++17中得到极大简化:

namespace MyYLibraries::Networking::FTP {}

可使用名称空间别名,为另一个名称空间指定一个更简短的新名称。例如:

namespace MYFTP = MYyLibraries::Networking::FTP;

1.1.3 ”字面量

还可以定义自己的自变量类型,这是一项高级功能,在第 11 章中介绍。可以在数值字面量中使用数字分隔符。数字分隔符是一个单引号。例如

23’456’789 0.123’456f

C++17 还增加了对十六进制浮点字面量的支持,如 0x3.ABCp-10、0Xb.cp121。

std:byte 单个字节在 C++17 之前,字符或无符号字符用于表示一个字节,但那些类型使得像在处理字符。std::byte 却能指明意图,即内存中的单个字节 std:byte b{42};

强类型枚举

enum class PieceType
{
	King = 1,
	Queen,
	Rook = 10,
	Pawn	
};

对于 enum class,枚举值名不会自动超出封闭的作用域,这表示总要使用作用域解析操作符:

PieceType piece = PieceType::King;

这也意味着给枚举值指定了更简短的名称,例如,用 King 替代 PieceTypeKing。另外,枚举值不会自动转换为整数。因此,下面的代码是不合法的:

if(PieceType::Queen == 2){...}

默认情况下,枚举值的基本类型是整型,但可采用以下方式加以改变:

enum class PieceType
{
	King = 1,
	Queen,
	Rook = 10,
	Pawn
};

int main()
{
	PieceType piece = PieceType::King;

	if (piece == PieceType::King)
	{
		/* ... */
	}

	return 0;
}

注意:

建议用类型安全的 enum class 枚举替代类型不安全的 enum 枚举。

\2. if语句的初始化器

C++17 允许在 让语句中包括一个初始化器,语法如下;

if(<initializer>:<conditional_expression>){<body>}

中引入的任何变量只在<conditional_expression>和中可用。此类变量在 证 语句以外不可用。此时还不到列举此功能有用示例的时候,下面只列出它的形式:

if(Employee employee = GetEmployee();employee.salary>1000){...}

在这个示例中,初始化器获得一名雇员,以及检查所检索雇员的薪水是否超出1000 的条件。只有满足条件才执行 证语句体。本书将穿插列举具体示例。

switch 语句

switch 是另一种根据表达式值执行操作的语法。在 C++中,switch 语句的表达式必须是整型、能转换为整型的类型、枚举类型或强类型枚举,必须与一个常量进行比较,每个常量值代表一种“情况(casej”,如果表达式与这种情况匹配,随后的代码行将会被执行,直至遇到 break 语句为止。此外还可提供 default 情况,如果没有其他情况与表达式值匹配,表达式值将与 default 情况匹配。下面的伪代码显示了 switch 语句的常见用法;

switch (menuItem) 
{
    case OpenMenuItem:
    // Code to open a file
    break;
    case SaveMenuItem:
    // Code to save a file
    break;
    default:
    // Code to give an error message
	break;
}

switch 语句总可转换为 ibelse 语句。前面的 switch 语句可以转换为

if (menuItem == OpenMenuItem) {
// Code to open a file
} 
else if(menuItem == SaveMenuItem)
{
	//Code to save a file
}
else
{
	//Code to give an error:message
}

如果你想基于表达式的多个值(而非对表达式进行一些测试)执行操作,通常使用 switch 语句。此时,switch语句可避免级联使用 计else 语句。如果只需要检查一个值,则应当使用让或让else 语句。一旦找到与 switch 条件匹配的 case 表达式,就执行其后的所有语句,直至遇到 break 语句为止。即使遇到另一个 case 表达式,执行也会继续,这称为 fallthrough。下例有一组语句,会为不同的 case 执行:

switch(backgroundColor)
{
	case Color::DarkBlue:
	case Color::Black:
		break;
	case Color::Red:
}

如果你无意间忘掉了 break 语句,fallthrough 将成为 bug 的来源。因此,如果在 switch 语句中检测到了allthrough,编译器将生成警告信息,除非像上例那样 case 为室。从 C++17 开始,你可以使用[[fallthrough]]特性,告诉编译器某个 fallthrough 是有意为之,如下所示:

switch(backgroundColor)
{
    case Color::DarkBlue:
        doSomethingForDarkBlue();
        [[fallthrough]];
    case Color::Black;
        doSomethingForBlackOrDarkBlue ();
    	break;
    case Color::Red:
	case Color::Green:
	// code to execute for a red or green background color
	break;	
}

\4. switch 语句的初始化器

与if语名一样,C++17 支持在 switch 语句中使用初始化器。语法如下:

switch (<initializer> : <expression>) { <body> }

中引入的任何变量将只在和中可用。它们在 switch 语句之外不可用。

函数返回类型的推断

C++14 允许要求编译器自动推断出函数的返回类型。要使用这个功能,需要把 auto 指定为返回类型

auto addNumbers (int number1,int number2){  return numberl + number2;}

编译器根据 retum 语句使用的表达式推断返回类型。函数中可有多个 retum 语句,但它们应解析为相同的类型。这种函数甚至可包含递归调用(调用自身),但函数中的第一个 retum 语句必须是非递归调用。

当前函数的名称

每个函数都有一个预定义的局部变量_func _,其中包含当前函数的名称。这个变量的一个用途是用于日志记录;

int addNumbers (int number1,int number2){	std::cout << "Entering function " << func_ << std::end1;	return numberl + number27}

std::array

上一节讨论的数组来自C,仍能在 C++中使用。但 C++有一种大小固定的特殊容器 std::array,这种容器在头文件中定义。它基本上是对 C 风格的数组进行了简单包装。用 std:array 替代 C 风格的数组会带来很多好处。它总是知道自身大小; 不会自动转换为指针,从而避免了某些类型的bug; 具有迭代器,可方便地遍历元素。第 17 章将详细讲述迭代器。

下例演示了 array 容器的用法。在 array<int,3>中,array 后面的尖括号在第 12 章中讨论。 将尖括号用于 array,足以令人记住,必须在尖括号中指定两个参数。第一个参数表示数组中元素的类型,第二个参数表示数组的大小。

#include <iostream>
#include <array>
using namespace std;
int main()
{
	array<int, 3> arr = { 9, 8, 7 };
	cout << "Array size = " << arr.size() << endl;
	cout << "2nd element = " << arr[1] << endl;
	return 0;
}

注意:

C 风格的数组和 std:array 都具有固定的大小,在编译时必须知道这一点。在运行时数组不会增大或缩小。

结构化绑定

C++17 引入了结构化绑定(stuctured bindings)的概念,人允许声明多个变量,这些变量使用数组、结构、pair或元组中的元素来初始化。例如,假设有下面的数组:

std::array<int,3>values = {11,22,33};

可声明三个变量 x、y 和 z,使用其后数组中的三个值进行初始化。注意,必须为结构化绑定使用 auto 关键字。例如,不能用 int 替代 auto。

auto {x,y,z} = values;

使用结构化绑定声明的变量数量必须与右侧表达式中的值数量匹配。如果所有非静态成员都是公有的,也可将结构化绑定用于结构。例如:

struct Point{double mX,mY,mZ;};
Point point;
point.mX = 1.0;point.mY = 2.0;point.mZ = 3.0;
auto{x,y,z} = point;

初始化列表

#include <iostream>
#include <initializer_list>

using namespace std;

int makeSum(initializer_list<int> lst)
{
	int total = 0;
	for (int value : lst) {
		total += value;
	}
	return total;
}

int main()
{
	int a = makeSum({ 1,2,3 });
	int b = makeSum({ 10,20,30,40,50,60 });
	cout << a << endl;
	cout << b << endl;
	cout << makeSum({ 1, 2, 3 }) << endl;
	return 0;
}

智能指针

为避免常见的内存问题,应使用智能指针蔡代通常的 C 样式“裸”指针。智能指针对象在超出作用域时,例如在函数执行完毕后,会自动释放内存。C++中有两种最重要的智能指针: std::unique_ptr 和 std::shared_ptr。

unique_ptr 类似于普通指针,但在 unique_ptr 超出作用域或被删除时,会自动释放内存或资源。unique_ptr只属于它指向的对象。unique_ptr 的一个优点是,内存和资源始终被释放,即使执行返回语句或抛出异常时(见稍后的讨论)。这简化了编码; 例如,如果一个函数有多个返回语句,你不必记着在每个返回语句前释放资源。要创建 unique_ptr,应当使用 std::make_unique<>()。例如,不要编写以下代码:

Employee* anEmployee = new Employee;
//...
delete anEmployee;

而应当编写;

auto anEmployee = make_unique<Employee>();

注意这样一来,将不再调用 delete,因为这将自动完成。本章后面的“类型推断”小节将详细讨论 auto 关键字。这里只需要了解,auto 关键字告诉编译器自动推断变量的类型,因此你不必手动指定完整类型 。

unique ptr 是一个通用的智能指针,它可以指向任意类型的内存。所以它是一个模板。模板需要用尖括号指定模板参数。在尖括号中必须指定 unique_ptr 要指向的内存类型。模板详见第 12 章和第 22 章,而智能指针在本书开头介绍,以便在全书中使用。可以看出,它们很容易使用。make_unique()在 C++14 中引入。如果你的编译器与 C++14 不兼容,可使用如下形式的 unique_ptr(注意,现在必须将 Employee 类型指定两次):

unique_ptr<Employee> anEmployee(new Employee);

可像普通指针那样使用 anEmployee 智能指针,例如,

if(anEmployee)
{
	cout<<"Salary:"<<anEmployee->salary<<endl;
}

unique_ptr 也可用于存储 C 风格的数组。下例创建一个包含 10 个 Employee 实例的数组,将其存储在unique_ptr 中,并显示如何访问该数组中的元素:

auto employees = make_unique<Employee[]>(10);
cout<<"Salary: "<<employees[0].salary<<endl;

shared_ptr 允许数据的分布式“所有权”。每次指定 shared_ptr 时,都递增一个引用计数,指出数据又多了一位“拥有者”。shared_ptr 超出作用域时,就递减引用计数。当引用计数为 0 时,就表示数据不再有任何拥有者,于是释放指针引用的对象。

要创建 shared_ptr,应当使用 std::make_shared<>(),它类似于 make_unique<>():

auto anEmployee = make_shared<Employee>();
if(anEmployee)
{
	cout<<"Salary: "<<anEmployee->Salary<<endl;
}

第 7 章将详细讨论内存管理和智能指针,但由于 unique_ptr 和 shared_ptr 的基本用法十分简单,它们总是用在本书的示例中。

注意

普通的裸指针仅允许在不涉及所有权时使用,否则默认使用 unique_ptr。如果需要共享所有权,就使用Shared_ptr。如果知道 auto_ptr,应忘记它,因为 C++11/14 不赞成使用它,而 C++17 已经废齐了它。

const 的多种用法

在 C++中有多种方法使用 const 关键字。所有用法都是相关的,但存在微妙差别。第 11 章将介绍 const 的所有用法。下面将讲述最常见的用法。

\1. 使用 const 定义常量

如果已经认为关键字 const 与常量有一定关系,就正确地揭示了它的一种用法。在 C 语言中,程序员经常使用预处理器的#define 机制声明一个符号名称,其值在程序执行时不会变化,例如版本号。在 C++中,鼓励程序员使用 const 取代#define 定义常量。使用 const 定义常量就像定义变量一样, 只是编译器保证代码不会改变这个值。

const int versionNumberMajor = 2;const int versionNumberMinor = 1;const std::string productName = "";

\2. 使用 const 保护参数

在 C++中,可将非 const 变量转换为 const 变量。为什么想这么做呢? 这提供了一定程度的保护,防止其他代码修改变量。如果你调用同事编写的一个函数,并且想确保这个函数不会改变传递给它的参数值,可以告诉同事让函数采用 const 参数。如果这个函数试图改变参数的值,就不会编译。

在下面的代码中,调用 mysteryFunction()时 string自动转换为 const string。如果编写 mysteryFunction()的人员试图修改所传递字符串的值,代码将无法编译。有绕过这个限制的方法,但是需要有意识地这么做,C++只是阻止无意识地修改 const 变量。

void mysteryFunction(const std::string *someString){	*someString = "Test";}int main(){	std::string myString = "The string";	mysteryFunction(&myString);	return 0;}

引用

C++中的引用允许给已有变量定义另一个名称,例如:

int x = 42;
int &xReference = x;

给类型附加一个&,则指示相应的变量是引用。这不是一个普通变量,但仍在使用;在幕后它实际上是一个指向原始变量的指针。变量 x 和引用变量 xReference 指向同一个值。如果通过其中一个更改值,则也可在另一个中看到更改。

\1. 按引用传递

通常,给函数传递变量时,传递的是值。如果函数接收整型参数,实际上传入的是整数的一个副本,因此不会修改原始变量的值。C 中通常使用栈变量中的指针,以允许函数修改另一个堆栈帧中的变量。通过对指针进行解引用,函数可以更改表示该变量的内存(即使该变量不在当前堆栈帧中)。这种方法的问题在于,它将指针语法的复杂性带入了原本简单的任务中。

在 C++中,不是给函数传递指针,而是提供一种更好的机制,称为“按引用传递”,其中,参数是引用而非指针。下面是 addOne()函数的两个版本。第一个版本不会影响传递给它的变量,因为变量是按照值传递的,因此函数接收的是传递给它的值的一个副本。第二个版本使用了引用,因此可以改变原始变量的值。

void addOne(int i)
{
	i++;
}
void addOne(int &i)
{
	i++;
}

调用具有整型引用参数的 addOne0)函数的语法与调用具有整型参数的 addOne()函数没有区别。

int myInt = 7;
addOne(myInt);

如果函数需要返回一个庞大的结构或类(见稍后的讨论),则复制成本高昂, 你经常看到,函数为这样的结构或类使用非 const 引用,此后进行修改,而非直接返回。很久以前,推荐使用这种方法,以免在从函数返回结构或类时由于创建副本而影响性能。从 C++11 开始,再也不必这么做了; 原因在于有了 move 语义,move语义直接从函数返回结构或类,不需要任何复制,十分有效。第 9 章将详细讨论 move 语义。

按 const 引用传递

经常可以看到,代码为函数使用 const 引用参数。乍看上去这有点自相矛盾,引用参数允许在另一种环境中改变变量的值,而 const 应该会阻止这种改变。const 引用参数的主要价值在于效率。当向函数传递值时,会制作一个完整副本。当传递引用时,实际上只是传递一个指向原始数据的指针,这样计算机就不需要制作副本。通过传递 const 引用,可做到二者兼顾一不需要副本,原始变量也不会修改。

在处理对象时,const 引用会变得更重要,因为对象可能比较庞大,复制对象可能需要很大的代价。第 11章将讲述如何处理此类复杂问题。下面的示例说明了如何把 std::string 作为 const 引用传递给函数:

void printString(const std::string& myString)
{
	std::cout<<"myString"<<std::endl;
}
int main()
{
	std::string someString = "Hello world";
	printString(someString);
	printString("Hello world");
	return 0;
}

注意:

如果需要给函数传递对象,最好按 const 引用(而非值) 传递它。这样可以防止多余的复制。如果函数需要修改对象,则为其传递非 const 引用。

异常

C++是一种非常灵活的语言,但并不是非常安全。编译器允许编写改变随机内存地址或者尝试除以 0 的代码(计算机无法处理无穷大的数值)。异常就是试图增加一点安全性的语言特性。异常是一种无法预料的情形。例如,如果编写一个获取 Web 页面的函数,就有几件事情可能出错,包含页面的 Intemet 主机可能被关闭,页面可能是空白的,或者连接可能会丢失。处理这种情况的一种方法是,从函数返回特定的值,如 nullptr 或其他错误代码。异常提供了处理此类问题的更好方法。

异常伴随着一些新术语。当某段代码检测到异常时,就会抛出一个异常。另一段代码会捕获这个异常并执行恰当的操作。下例给出一个名为 divideNumbers0的函数,如果调用者传递给分母的值为 0,就会她出一个异常。使用 std::invalid_argument 时需要 。

double divideNumbers(double numerator,double denominator){	if(denominator == 0)	{		throw invalid_argument("Denominator cannot be 0");	}	return numerator / denominator;}

当执行 throw 行时,函数将立刻结束而不会返回值。如果调用者将函数调用放到 trycatch 块中,就可以捕获异常并进行处理,如下所示:

try{	cout<<divideNumbers(2.5,0.5)<<endl;	cout<<divideNumbers(2.3,0)<<endl;	cout<<divideNumbers(4.5,2.5)<<endl;}catch(const invalid_argument & exception){	cout<<"Exception caught: "<<exception.what()<<endl;}

第一次调用 divideNumbers()成功执行,结果会输出给用户。第二次调用会抛出一个异常,不会返回值,唯一的输出是捕获异常时输出的错误信息。第三次调用根本不会执行,因为第二次调用抛出了一个异常,导致程序跳转到 catch 块。前面代码块的输出是:

5An exception was caught: Denominator cannot be 0.

C++的异常非常灵活,为正确使用异常,需要理解抛出异常时堆栈变量的行为,必须正确捕获并处理必要的异常。前面的示例中使用了内建的 std::invalid_argument 类型,但最好根据所抛出的具体错误,编写自己的异常类型。最后,C++编译器并不强制要求捕获可能发生的所有异常。如果代码从不捕获任何异常,但有异常抛出,程序自身会捕获异常并终止。第 14 章将进一步讨论异常的这些更复杂的方面。

类型推断

类型推断允许编译器自动推断出表达式的类型。类型推断有两个关键字: auto 和 decltype。

\1. 关键字 auto

关键字 auto 有多种完全不同的含义:

推断函数的返回类型,如前所述。

结构化绑定,如前所述。

推断表达式的类型,如前所述。

推断非类型模板参数的类型,见第 12 章。

decltype(auto),见第 12 章。

其他函数语法,见第 12 章。

通用 lambda 表达式,见第 18 章。

auto 可用于告诉编译器,在编译时自动推断变量的类型。下面的代码演示了在这种情况下,关键字 auto 最简单的用法:

auto x = 123;

警告:

始终要记住,auto 去除了引用和 const 限定符,从而会创建副本! 如果不需要副本,可使用 auto&或 const auto&c。

\2. 关键字 decltype

关键字 decltype 把表达式作为实参,计算出该表达式的类型。例如:

int x = 123;decltype(x) y = 456;

在这个示例中,编译器推断出 y 的类型是 int,因为这是 x 的类型。auto 与 decltype 的区别在于,decltype 未去除引用和 const 限定符。再来分析返回 const string 引用的 foo() 函数。按如下方式使用 decltype 定义 亿,导致 人 的类型为 const string&,从而不生成副本:

decltype(foo()) f2 = foo(0);

刚开始不会觉得 decltype 有多大价值。但在模板环境中,decltype 会变得十分强大,详见第 12 和 22 章。

作为面向对象语言的 C++

如果你是一位 C 程序员,可能会认为本章讲述的内容到目前为止只是传统 C 语言的补充。顾名思义,C++语言在很多方面只是“更好的C”。这种观点忽略了一个重点: 与 C 不同,C++是一种面向对象的语言。面向对象程序设计(OOP)是一种完全不同的、更趋自然的编码方式。如果习惯使用过程语言,如 C 或者Pascal,不要担心。第 5 章讲述将观念转换到面向对象范型所需要的所有背景知识。如果你已经了解 OOP 的理论,下面的内容将帮助你加速了解(或者回顾)基本的 C++对象语法。 5

定义类

类定义了对象的特征。在 C++中,类通常在头文件(b)中声明,在对应的源文件cpp)中定义其非内联方法和静态数据成员。下面的示例定义了一个基本的机票类。这个类可根据飞行的里程数以及顾客是不是“精英超级奖励计划”的成员计算票价。这个定义首先声明一个类名,在大括号内声明了类的数据成员(属性)以及方法(行为)。每个数据成员以及方法都具有特定的访问级别: public、protected 或 private。这些标记可按任意顺序出现,也可重复使用。public 成员可在类的外部访问,private 成员不能在类的外部访问,推荐把所有的数据成员都声明为 private,在需要时,可通过 public 读取器和设置器来访问它们。这样,就很容易改变数据的表达方式,同时使 public 接口保持不变。关于 protected 的用法,将在第5 和 10 章中介绍“继承”时讲解。

#include <string>class AirlineTicket{	public:    	AirlineTicket();    	~AirlineTicket();    	    	double calculatePriceInDollars() const;    	const std::string& getPassengerName() const;    	void setPassengerName(const std::string& name);        	int getNumberOfMiles() const;    	void setNumberOfMiles(int miles);        	bool hasEliteSuperRewardsStatus() const;    	void setHasEliteSuperRewardsStatus(bool status);    private:    	std::string mPassengerName;    	int mNumberOfMiles;    	bool mHasEliteSuperRewardsStatus;}

注意:

为遵循 const 正确性原则,最好将不改变对象的任何数据成员的成员函数声明为 const。相对于非 const 成员有函数“修改器”,这些成员函数也称为“检测器”。

与类同名但没有返回类型的方法是构造函数,当创建类的对象时会自动调用构造函数。~之后紧接着类名的方法是析构函数,当销毁对象时会自动调用。使用构造函数,可通过两种方法来初始化数据成员。推荐的做法是使用构造函数初始化器(constructor initialize),即在构造函数名称之后加上冒号。下面是包含构造函数初始化器的 AirlineTicket 构造函数,

AirlineTicket::AirlineTicket()
	:mPassengerName("Unknow Passenger")
    ,mNumberOfMiles(0)
    ,mHasEliteSuperRewardsStatus(false)
{

}

第二种方法是将初始化任务放在构造函数体中,如下所示:

AirlineTicket::AirlineTicket()
{
	//Initialie data members
	mPassengerName = "Unknown Passenger";
	mNumberOfMiles = 0;
	mHasEliteSuperRewardsStatus = false;
}

如果构造函数只是初始化数据成员,而不做其他事情,实际上就没必要使用构造函数,因为可在类定义中直接初始化数据成员。例如,不编写 AirlineTicket 构造函数,而是修改类定义中数据成员的定义,如下所示:

private:
	std::string mPassengerName = "Unknown Passenger";
	int mNumberOfMiles = 0;
	bool mHasEliteSuperRewardsStatus = false;

如果类还需要执行其他一些初始化类型,如打开文件、分配内存等,则需要编写构造函数进行处理。下面是 AirlineTicket 类的析构函数:

AirlineTicket::~AirlineTicket()
{

}

这个析构函数什么都不做,因此可从类中删除。这里之所以显示它,是为了让你了解析构函数的语法。如果需要执行一些清理,如关闭文件、释放内存等,则需要使用析构函数。第 8 和 9 章详细讨论析构函数。一些 AirlineTicket 类方法的定义如下所示;,

double AirlineTicket::calculatePriceInDollars() const
{
	if(hasEliteSuperRewardsStatus())
    {
        return 0;
    }
    return getNumberOfMiles() * 0.1;
}

const string& AirlineTicket::getPassengerName() const
{
    return mPassengerName;
}

void AirlineTicket::setPassengerName(const string& name)
{
    mPassengerName = name;
}

统一初始化

在 C++ll 之前,初始化类型并非总是统一的。例如,考虑下面的两个定义,其中一个作为结构,另一个作为类,

struct CircleStruct
{
	int x,y;
	double radius;
};

class CircleClass
{
    public:
    	CircleClass(int x,int y,double radius)
            :mX(x),mY(y),mRadius(radius){}
    private:
    	int mX,mY;
        double mRadius;
};

在 C++11 之前,CircleStruct 类型变量和 CircleClass 类型变量的初始化是不同的;

CircleStruct myCircle1 = {10,10,2.5};
CircleClass myCircle2(10,10,1.5);

对于结构版本,可使用{…}语法。然而,对于类版本,需要使用函数符号(…)调用构造函数。自 C++ll 以后,允许一律使用{…}语法初始化类型,如下所示:

CircleStruct myCircle3 = {10,10,2.5};
CircleClass myCircle4 = {10,10,2.5};

定义 myCircle4 时将自动调用 CircleClass 的构造函数。甚至等号也是可选的,因此下面的代码与前面的代码等价:

CircleStruct myCircle5{10,10,2.5};
CircleClass myCircle6{10,10,2.5};

统一初始化并不局限于结构和类,它还可用于初始化 C++中的任何内容。例如,下面的代码把所有 4 个变量都初始化为 3:

int a = 3;
int b{3};
int c = {3};
int d{3};

统一初始化还可用于将变量初始化为 0,使用默认构造函数构造对象,将基本整数类型(如 char 和 int 等)初始化为 0,将浮点类型初始化为 0.0,将指针类型初始化为 nullptr。为此,只需要指定一系列空的大括号,例如:

int e[];

使用统一初始化还可以阻止窗化arrowing)。C++隐式地执行窗化,例如:

void func(int i){}
int main()
{
	int x = 3.14; 
	func(3.14);
}

这两种情况下,C++在对x赋值或调用 func()之前,会自动将 3.14 截断为 3。注意有些编译器会针对窗化给出警告信息,而另一些编译器则不会。使用统一初始化,如果编译器完全支持 C++11 标准,x 的赋值和 func()的调用都会生成编译错误:

void func(int i){}
int main()
{
	int x = {3.14}; // Error because narrowing
	func({3.14});// Error because narrowing
}

统一初始化还可用来初始化动态分配的数组:

int* pArray = new int[4]{0,1,2,3};

统一初始化还可在构造函数初始化器中初始化类成员数组:

class MyClass
{
	public:
		MyClass():mArray{0,1,2,3,}{}
    private:
    	int mArray[4];
};

统一初始化还可用于标准库容器,如 std::vector,见稍后的描述。

直接列表初始化与复制列表初始化

有两种初始化类型使用包含在大括号中的初始化列表:

复制列表初始化: T obj = {arg1, arg2,…}

直接列表初始化: T obj {argl, arg2,,.…};

在 C++17 中,与 auto 类型推断相结合,直接列表初始化与复制列表初始化存在重要区别。从C++17 开始,可得到以下结果;

auto a = {11}; // initializer_1ist<int>
auto b = {11,22}; // initializer_1ist<int>
//Direct list initialization
auto c{11}; //int
auto d{11,22};// Error,too many elements.

注意,对于复制列表初始化,放在大括号中的初始化器的所有元素都必须使用相同的类型。例如,以下代码无法编译

auto b = {11,22.33};// compilation error

在早期标准版本(C++11/14)中,复制列表初始化和直接列表初始化会推导出 initializer list<>:

//copy list initialization
auto a = {11}; //initializer_ list<int>
auto b = {11,22};//initializer_ list<int>

动态字符串

在将字符串当成一等对象支持的语言中,字符串有很多有吸引人的特性,例如可扩展至任意大小,或能提取或替换子字符串。在其他语言(如 C 语言)中, 字符串几乎就像后加入的功能; C 语言中并没有真正好用的 string数据类型,只有固定的字节数组。“字符串库”只不过是一组非常原始的函数,甚至没有边界检查的功能。C++提供了 string 类型作为一等数据类型。

C 风格的字符串

在C 语言中,字符串表示为字符的数组。字符串中的最后一个字符是 null 字符(\0),这样,操作字符串的代码就知道字符串在哪里结束。 官方将这个 null 字符定义为NUL, 这个拼写中只有一个 志, 而不是两个L。 NUL和NULL 指针是两回事。尽管 C++提供了更好的字符串抽象,但理解 C 语言中使用的字符串技术非常重要,因为在 C++程序设计中仍可能使用这些技术。最常见的一种情况是 C++程序调用某个第三方库中(作为操作系统接口的一部分)用 C 语言编写的接口。

高级数值转换

std 名称空间包含很多辅助函数,以便完成数值和字符串之间的转换。下面的函数可用于将数值转换为字符串。所有这些函数都负责内存分配,它们会创建一个新的 string 对象并返回。

string to_string(int val);
string to_string(unsigned val);
string to_string(long val);
string to_string(unsigned long val);
string to_string(long long val);
string to_string(unsigned long long val);
string to_string(float val);
string to_string(double val);
string to_string(long double val);

这些函数的使用非常简单直观。例如,下面的代码将 long double 值转换为字符串;

long double d = 3.14L;string s = to_string(d);

通过下面这组也在 std 名称空间中定义的函数将字符串转换为数值。在这些函数原型中,str 表示要转换的字符串,idx 是一个指针,这个指针将接收第一个未转换的字符的索引,base 表示转换过程中使用的进制。idx指针可以是空指针,如果是空指针,则被忽略。如果不能执行任何转换,这些函数会抛出 invalid_argument 异 常,如果转换的值超出返回类型的范围,则抛出 out_of range 异常。

int stoi(const string& str,size_t *idx = 0,int base = 10);
long stol(const string& str,size_t *idx = 0,int base = 10);
unsigned long stoul(const string& str,size_t *idx = 0,int base = 10);
long long stoll(const string& str,size_t *idx = 0,int base = 10);
unsigned long long stoull(const string& str,size_t *idx = 0,int base = 10);
float stof(const string& str,size_t *idx = 0);
double stod(const string& str,size_t *idx = 0);
long double stold(const string& str,size_t *idx = 0);

下面是一个示例:

const string toParse = " 123USD";
size_t index = 0;
int value = stoi(toParse,&index);
cout<<"Parsed value: "<<value<<endl;
cout<<"First non-parsed character: "<<toParse[index]<<"."<<endl;

输出如下所示:

Parsed value:123First non-parsed character:'U'

\5. 低级数值转换

​ C++17 也提供了许多低级数值转换函数,这些都在头文件中定义。这些函数不执行内存分配,而使用由调用者分配的缓存区。另外,对它们进行优化,以实现高性能,并独立于本地化(有关本地化的内容,详见第 19 章)。最终结果是,与其他更高级的数值转换函数相比,这些函数的运行速度要快几个数量级。如果性能要求高, 需要进行独立于本地化的转换, 则应当使用这些函数; 例如, 在数值数据与人类可读格式(如 JSON、XML 等)之间进行序列化/反序列化。要将整数转换为字符,可使用下面一组函数:

to_chars_result to_chars(char* first,char* last,IntegerT value,int base = 10)

​ 这里,IntegerT 可以是任何有符号或无符号的整数类型或字符类型。结果是 to_chars_result 类型,类型定义如下所示:

struct to_chars_result{	char* ptr;	errc ec};

如果转换成功,ptr 成员将等于所写入字符的下一位置(one-pastthe-end)的指针;如果转换失败(即 ec==errc::value too large),则它等于 last。

下面是一个使用示例,

std::string out(10,' ');auto result = std::to_chars(out.data(),out.data() + out.size(),12345);if(result.ec == std::errc()) /*Conversion successful. */{

使用第 1 章介绍的 C++17 结构化绑定,可以将其写成:

std::string out(10,' ');auto [ptr,ec]  = std::to_chars(out.data(),out.data()+out.size(),12345);if(ec == std::errc()){ /*Conversion successful. */

类似地,下面的一组转换函数可用于浮点类型,

to_chars_result to_chars(char* first,char* last, FloatT value);
to_chars_result to_chars(char* first,char* last, FloatT value,chars_format format);
to_chars_result to_chars(char* first,char* last, FloatT value,chars_format format,int precision);

这里,FioatT 可以是 float、double 或long double。可使用 chars_format 标志的组合来指定格式:

enum class chars_format
{
	scientific,
	fixed,
	hex,
	general = fixed | scientific
}

默认格式是 chars_format’:general,这将导致 to_chars()将浮点值转换为(-)ddd.ddd 形式的十进制表示形式,或Cdddde士dd 形式的十进制指数表示形式,得到最短的表示形式,小数点前至少有一位数字(如果存在)。如果指定了格式,但未指定精度,将为给定格式自动确定最简短的表示形式,最大精度为 6 个数字。对于相反的转换,即将字符序列转换为数值,可使用下面的一组函数;

from_chars_result from_chars(const char* first,const char* last,IntegerT& value,int base = 10);
from_chars_result from_chars(const char* fist,const char* last,FloatT& value,chars_format format = chars_format::general);

这里,from_chars_result 的类型定义如下:

//Here.from_chars_result is a type defined as follows;
struct from_chars_result
{
    const char* ptr;
    errc ec;
};

这里,结果类型的 ptr 成员是指向未转换的第一个字符的指针,如果所有字符都成功转换,则它等于 last。如果所有字符都未转换,则 ptr 等于 first,错误代码的值将为 errc::invalid argument。如果解析后的值过大,无法由给定类型表示,则错误代码的值将是 errc::result_out_of range。注意,from_chars()不会忽略任何前导空白。

std::string_view 类

在 C++17 之前,为接收只读字符串的函数选择形参类型一直是一件进退两难的事情。它应当是 const char*吗? 那样的话, 如果客户端可使用 std::string, 则必须调用其上的 c_str()或 data()来获取 const char*。 更糟糕的是,函数将失去 std::string 良好的面向对象的方面及其良好的辅助方法。或许,形参应改用 const std::string&? 这种情况下,始终需要 std::string。例如,如果传递一个字符串字面量,编译器将不加通告地创建一个临时字符串对象(其中包含字符串字面量的副本),并将该对象传递给函数,因此会增加一点儿开销。有时,人们会编写同一函数的多个重载版本,一个接收 const char*,另一个接收 const string&; 但显然,这并不是一个优雅的解决方案。

在 C++17 中,通过引入 std::string _view 类解决了所有这些问题,std::string view 类是 std::basic_string_view类模板的实例化,在<string_view>头文件中定义。string_view 基本上就是 const string&的简单替代品,但不会产生开销。它从不复制字符串,string_view 支持与 std::string 类似的接口。一个例外是缺少 c_str(),但 data()是可用的。另外,string_view 确实添加了 remove_prefix(size_t)和 remove_suffix(size_t)方法,前者将起始指针前移给定的偏移量来收缩字符串,后者则将结尾指针倒退给定的偏移量来收缩字符串。注意,无法连接一个 string 和一个 string_view。下面的代码将无法编译:

string str = "Hello";
string_view sv = " world";
auto result = str + sv;

为进行编译,必须将最后一行蔡代为

auto result = str + sv.data();

如果知道如何使用 std::string,那么使用 string_view 将变得十分简单,如下面的代码片段所示。extractExtension()函数提取给定文件名的扩展名并返回。注意,通常按值传递 string_views,因为它们的复制成本极低。它们只包含指向字符串的指针以及字符串的长度。

string_view extractExtension(string_view fileName){	return fileName.substr(fileName.rfind('.'));}

该函数可用于所有类型的不同字符串:

string fileName = R"(c:\temp\my_file.ext)";cout<<"C++ string: "<<extractExtension(fileName)<<endl;const char* cString = R"((c:\temp\my_file.ext))";cout<<"C string: "<<extractExtension(cString)<<endl;cout<<"Literal: "<<extractExtension(R"(c:\temp\my_file.ext)")<<endl;

在对 extractExtension()的所有这些调用中,并非进行单次复制。extractExtension()函数的 fleName 参数只是指针和长度,该函数的返回类型也是如此。这都十分高效。还有一个 string_view 构造函数,它接收任意原始缓冲区和长度。这可用于从字符串缓冲区(并非以NUL阁止)构建 string_view。如果确实有一个以 NUL 终止的字符串缓冲区,但你已经知道字符串的长度,构造函数不必再次统计字符数目,这也是有用的。

const char* raw = /*...*/
size_t length = /*...*/
cout<<"Raw:"<<extractExtension(string_view(raw,length))<<endl;

无法从 string view 隐式构建一个 string。要么使用一个显式的 string 构造函数,要么使用 string_view::data()成员。例如,假设有以下接收 const string&的函数

void handleExtension(const string& extension){}

不能采用如下方式调用该函数;

handleExtension(extractExtension("my_file.ext"));

下面是两个可供使用的选项:

handleExtension(extractExtension("my file.ext").data());
handleExtension(string(extractExtension("my file.ext")))

注意:

在每当函数或方法需要将只读字符串作为一个参数时, 可使用 std::string_view 替代 const std::string&或 const char*

std::string_view 字面量

可使用标准的用户定义的字面量 sv,将字符串字面量解释为 std::string_view。例如:auto SV = "MY string_View"sV7标准的用户定义的字面量 sv 需要 using namespace std::string_view_literals;或 using namespace std; 。

非标准字符串

许多 C++程序员都不使用 C++风格的字符串,这有几个原因。一些程序员只是不知道有 string 类型,因为它并不总是 C++规范的一部分。其他程序员发现,C++ string 没有提供他们需要的行为,所以开发了自己的字符串类型。也许最常见的原因是,开发框架和操作系统有自己的表达字符串的方式,例如 Microsof MFC 中的CString 类。它常用于向后兼容或解决遗留的问题。在 C++中启动新项目时,提前确定团队如何表示字符串是非常重要的。务必注意以下几点

e不应当选择 C 风格的字符串表示。

可对自己所使用框架中可用的字符串功能进行标准化,如 MFC、QT 内置的字符串功能。

果为字符串使用 std::string,应当使用 std::string_view 将只读字符串作为参数传递给函数,和否则,看一下你的框架是否支持类似于 string_view 的类。

如果你每天花费数个小时使用键盘编写代码,你应该为你的工作感到骄傲,编写可以完成任务的代码只是程序员全部工作的一部分而已。任何人都可以学习编写基本的代码, 编写具有风格的代码才算真正掌握了编码。本章讲述如何编写优秀的代码,还将展示几种 C++风格。简单地改变代码的风格可以极大地改变代码的外观。例如,Windows 程序员编写的 C++代码通常具有自己的风格,使用了 Windows 的约定。macOS 程序员编写的 C++代码与之相比几乎是完全不同的语言。如果打开的 C++源代码一点都不像你了解的 CH+,接触几种不同的风格有助于避免这种消沉的感觉。

编码风格

良好外观的重要性

编写文体上“良好”的代码很费时间。你或许在几个小时内就可以匆匆写出解析 XML 文件的程序。而编写功能分离、注释充分、结构清晰的相同程序可能要花费更长时间。这么做值得吗?

事先考虑

如果一名新程序员在一年之后不得不使用你的代码,你对代码有多少信心? 本书作者的一个朋友面对日益混乱的网络应用程序代码,让他的团队假想一名一年后加入的实习生。如果没有文档而函数有好几页长,这名可怜的实习生如何才能赶上代码的进度? 编写代码时,可以假定某个新人在将来不得不维护这些代码。你还记得代码如何运行吗? 如果你不能提供帮助会怎么样? 良好的代码由于便于阅读和理解,因此不存在这些问题。

良好风格的元素

在编程环境下,文档通常指源文件中的注释。当编写相关代码时,注释用来说明你当时的想法。这里给出的信息并不能通过阅读代码轻易地获取。

使用注释的原因

很明显,使用注释是一个好主意,但为什么代码需要注释? 有时程序员意识到注释的重要性,但没有完全理解为什么注释如此重要。本章将解释使用注释的全部原因。

\1. 说明用途的注释

使用注释的原因之一是说明客户如何与代码交互。通常而言,开发人员应当能够根据函数名、返回值的类型以及参数的类型和名称来推断函数的功能。但是,代码本身不能解释一切。有时,一个函数需要一些先置条件或后置条件,而这些需要在注释中解释。函数可能抛出的异常也应当在注释中解释。在笔者看来,只有当注释能提供有用的信息时才添加注释。因此,应由开发人员确定函数是否需要添加注释。经验丰富的程序员能可靠地确定这一点,但经验不足的开发人员则未必能做出正确的决策。因此,一些公司制定规则,要求头文件中每个公有访问的函数或方法都应该带有解释其行为的注释。某些组织喜欢将注释规范化,明确列出每个方法的目的、参数、返回值以及可能抛出的异常。

通过注释,可用自然语言陈述在代码中无法陈述的内容。例如,在C++中无法说明: 数据库对象的saveRecord()方法只能在 openDatabase()方法之后调用,和否则将抛出异常。但可以使用注释提示这一限制,如下所示:

/* * This method throws a "DatabaseNotOpenedException" * if the openDatabase () method has not been called yet. */int saveRecord (Record&g record)

C++语言强制要求指定方法的返回类型,但是无法说明返回值实际代表了什么。例如,saveRecord()方法的声明可能指出这个方法返回 int 类型(这是一种不良的设计决策,见下一节的讨论),但是阅读这个声明的客户不知道 int 的含义。注释可解释其含义;

/* * Returns: int * Rn integer representing the ID of the saved record. * This method throws a "DatabaseNotOpenedException" * if the openDatabase () method has not been called yet. */ int saveRecord (Record&g record)

如前所述,有些公司要求用正式方式记录有关函数的所有信息。下例演示了遵守这个原则的 saveRecord()方法

/*
* SaveRecord ()
* 	Saves the given record to the database.
* ParameterSs:
* 	Record& record: the record to save to the database.
* Returns: int
* 	an integer representing the ID of the saved record.
* Throws
* DatabaseNotOpenedException if the openDatabase() method has not
* been called yet.
*/
int saveRecord (Record& record) ;

但不建议使用这种风格的注释。前两行完全无用,因为函数名的含义不言自明。对形参的解释也不能添加任何附加信息。有必要记录这个 saveRecord()版本的返回类型表示的意义,因为它返回了泛型int。但是,更好的设计方式是返回 RecordID 而非普通的 int 类型,那样的话,就不需要为返回类型添加注释。RecordID 只是 int的类型别名(见第 11 章),但传达的信息更多。唯一必须保留的注释是异常。因此,建议使用如下 saveRecord()方法;

/* *Throws: *	 DatabaseNotOpenedException if the openDatabase () method has not * 	 been called yet. */ RecordID saveRecord(Record& record);

注意:

如果你的公司并未强制要求为函数编写正式的注释,那么在编写注释时,要遵循常识。 那些可通过函数名、返回值类型以及形参的类型和名称明显看出的信息,就不必添加到注释中。

有时函数的参数和返回值是泛型,可用来传递任何类型的信息。在此情况下应该清楚地用文档说明所传递的确切类型。例如,Windows 的消息处理程序接收两个参数 LPARAM 和 WPARAM,返回LRESULT。这些参数和返回值可以传递任何内容,但是不能改变它们的类型。使用类型转换,可以用它们传递简单的整数,或者传递指向某个对象的指针。文档应该是这样的:

/*
 * Parameters:
 * 		WPRARAM wParam: (WPARAM) (int) : Rn integer representing...
 * 		LPRARRAM 1Param: (LPRARRAM) (string*): RA string pointer representing...
 * 	Returns: (LRESULT) (Record*)
 * 		nullptr in case of an error,otherwise a pointer to a Record object
 * 		representing...
 */

用来说明复杂代码的注释

在实际源代码中,好的注释同样重要。在一个处理用户输入并将结果输出到控制台的简单程序中,阅读并理解所有代码可能很容易。然而在专业领域,代码的算法往往非常复杂、深奥,很难理解。考虑下面的代码。 这段代码写得很好,但是可能无法一眼就看出其作用。如果以前见过这个算法,你可能会认出它来,但是新人可能无法理解代码的运行方式。

void sort(int inArray[],size_t inSize)
{
    for(size_t i = 1;i < inSize; i++)
    {
        int element = inArray[i];
        size_t j = i - 1;
        while(j >= 0 && inArray[j] > element)
        {
            inArray[j + 1] = inArray[j];
            j--;
        }
        inArray[j+1] = element;
    }
}

较好的做法是使用注释描述所使用的算法,并说明(循环的)不变量。不变量(Invarian)是执行一段代码的过程中必须为真的条件,例如循环的迭代条件。下面是改良后的函数,顶部的注释在较高层次说明了这个算法,行内的注释解释了可能令人感到疑惑的特定行。

/*
 * Implements the "insertion sort"” algorithm。. The algorithm separates the
 * array into two parts--the sorted part and the unsorted part。 Each
 * element,starting at position 1,is examined。Everything earlier in the
 * array is in the sorted part,so the algorithm shifts each element over
 * until the correct Position is found to insert the current element .When
 * the algorithm finishes with the last element,the entire array is sorted.
 */
void sort(int inArray[],size_t inSize)
{
    // Start at Position 1 and examine each element.
    for(size_t i = 1;i < inSize; i++)
    {
	    //Loop lnVariant:
        //   All elements in the range 0 to i-l (inclusive) are sorted.
        int element = inArray[i];
        // j marks the position in the sorted part after which element
		// will be inserted.
        size_t j = i - 1;
        // As long as the current slot in the sorted array is higher than
		// element,shift values to the right to make room for inserting
		// (hence the name,"insertion sort") element in the right Position。
        while(j >= 0 && inArray[j] > element)
        {
            inArray[j + 1] = inArray[j];
            j--;
        }
        // At this Point the current position in the sorted array
		// is *not* greater than the element,sSo this is its new Position
        inArray[j+1] = element;
    }
}

新代码的长度有所增加,但通过注释,不熟悉排序算法的读者就能理解这个算法。

\3. 传递元信息的注释

使用注释的另一个原因是在高于代码的层次提供信息。元信息提供创建代码的详细信息,但是不涉及代码的特定行为。例如,某组织可能想使用元信息跟踪每个方法的原始作者。还可以使用元信息引用外部文档或其他代码。下例给出了元信息的几个实例,包括文件的作者、创建日期、提供的特性。此外还包括表示元数据的行内注释,例如对应某行代码的 bug 编号,提醒以后重新访问时代码中某个可能的问题。

/* * Ruthor: marcg                                , * Date:   110412 * Feature: PRD version 3,Feature 5.10 */RecordID saveRecord(Record& record){    if(!mDatabaseOpen)    {        throw DatabaseNotOpenedException();    }    RecordID id = getDB()->saveRecord(record);    if(id == -1) return -1; // added to address bug #142 - jsmith 110428	record.setId(id);    // TODO: What if setId() throws an exception? - akshayr 110501	return id;}

警告:

使用第 24 章讲述的源代码控制方案(也应当使用该方案),前几个示例中就不必使用所有元信息(TODO 注释除外)。源代码控制方案提供了带注释的修改历史,包括修改日期、修改人、对每个修改的注释(假定使用正确),以及对修改请求和 bug 报告的引用。应当使用描述性注释,分别签入(check-in)、提交每个修改请求或 bug修复。有了这样的系统,你不必手动跟踪元信息。

注释的风格

每个组织注释代码的方法都不同。在某些环境中,为了让代码文档具有统一标准,需要使用特定的风格。在其他环境中,注释的数量和风格由程序员决定。下例给出了注释代码的几种方法。

\1. 每行都加入注释

避免缺少文档的方法之一是在每行都包含一条注释。每行都加入注释,可以保证已编写的所有内容都有特定的理由。但在实际中,如果代码非常多,过多的注释会非常混乱、繁杂,无法做到。例如,下面的注释没有意义:

int result;                  // Declare an integer to hold the result.
result = doodad.getResult(); // Get the doodad's result.
if(result % 2 == 0)          // If the result modulo 2 is 0 ...
{
	logError();              // then log an errory
}
else                         // otherwise ...
{
	logSuccess();            // 1og success
}                            // Enad if/else
return result;               // Return the result

代码中的注释好像把每行代码当成容易阅读的故事来讲述。如果读者掌握基本的 C++技能, 这完全没有用。这些注释没有给代码引入任何附加信息。例如下面这行:

if(result % 2 == 0){} // If the result modulo 2 is 0

这行代码中的注释只是将代码翻译成英语, 并没有说明为什么程序员用 2 对结果求模。较好的注释应该是

if(result % 2 == 0){} // If the result is even ...

修改后的注释给出了代码的附加信息,尽管对于大多数程序员而言这非常明显。用 2 对结果求模是因为代码需要检测结果是不是偶数。

尽管注释太多,会使代码宛长、多余,但当代码很难理解时,这样做还是有必要的。下面的代码也是每行都有注释,介是这些注释确实有用。

// Calculate the doodad. The start,end,and offset values come from the
// table on page 96 of the "Doodad RPI v1.6".
result = doodad.calculate (kStart,kEnd,koffset)
// To' determine success or failure,Wwe need to bitwise AND the result with
// the processor-specific mask (see "Doodad RPI v1.6",Ppage 201) .
esult &= getProcessorMask () 7
// Set the user field value based on the "Marigold Formula."
// (see "Doodad API v1.6",Ppage 136) ,
setUserField((result + kMarigoldOffset) / MarigoldConstant + MarigoldConstant)7

这段代码的环境不明,但注释说明了每行代码的作用。如果没有注释,就很难解释与&和神秘的“Marigold Formula”相关的计算。

注意:

通常没必要给每行代码都添加注释,但当代码非常复杂,需要这样做时,不要只是将代码翻译成英语,而要解释代码实际上在做什么。

\2. 前置注释

团队可能决定所有的源文件都以标准注释开头。可以在该位置记录程序和特定文件的重要信息。在每个文件顶部加入的说明信息有:

最近的修改日期
原始作者
前面所讲的修改日志
文件给出的功能 ID
版权信息
文件或类的简要说明
未完成的功能
已知的bug

对于标有星号的条目,将通常由源代码控制解决方案自动处理。

开发环境可能允许创建模板,以自动启动具有前置注释的新文件。某些源代码控制系统(例如Subversion(SVN))甚至可以帮助填写元数据。例如,如果注释包含了字符串 I d Id Id,SVN 可以自动扩展注释,包含作者、文件名、版本和日期。下面给出了一个前置注释示例:

/*
 * $Id: Watermelon.cpp,123 2004/03/10 12:52:33 marcg $
 * Implements the basic functionality of a watermelon。R11 units are expressed
 * in terms of seeds per cubic centimeter. Watermelon theory is based on the
 * White paper "Algorithms for Watermelon Processing."
 * The following code is (c) copyright 2017,FruitSoft,Inc。RLL RIGHTS RESERVED
 */

固定格式的注释

以标准格式编写可被外部文档生成器解析的注释是一种日益流行的编程方法。在 Java 语言中,程序员可用标准格式编写注释, 允许 JavaDoc 工具自动为项目创建超文本文档。对于 C++而言, 免费工具 Doxygen(www.doxygen.org)可解析注释,自动生成 HTML 文档、类图、UNIX man 页面和其他有用的文档。Doxygen 其至可辨别并解析 C++程序中 JavaDoc 格式的注释。下面的代码给出 Doxygen 可以识别的 JavaDoc 格式的注释。

/**
 * ImPplements the basic functionality of a watermelon
 * TODO: Implement updated algorithmsl
 */
class Watermelon
{
    public:
    	/*
         * @param initialSeeds The starting number of seeds,must be > 0.
		 */
    	Watermelon(int initialSeeds);
    	/**
    	 * Computes the seed ratio,using the Marigold algorithm.
         * eparam SL1owCalc Whether or not to use long (slow) calculations
		 * return The marigold ratio
		 */
    	double calcSeedRatio(bool slowCalc);
};

Doxygen 可识别 C++语法和特定的注释指令,例如@param 和人@retum,并生成定制的输出。图 3-1 给出了Doxygen 生成的 HTML 类引用示例。

/**
 * The Watermelon constructor
 * @param initialSeeds The starting number of seeds,must be > 0
 */
Watermelon(int initialSeeds);

自动生成如图 3-1 所示的文档在开发时很有用,因为这些文档允许开发人员浏览类和类之间关系的高层描述。团队可以方便地定制 Doxygen 等工具,以处理所采用的注释格式。理想情况下,团队应该专门布置一台计算机来编写日常文档。

在这里插入图片描述

\4. 特殊注释

通常应根据需要编写注释,下面是在代码内使用注释的一些指导方针,

尽量避免使用冒犯性或令人反感的语言,因为你不知道将来谁会查看代码。

开一些内部的玩笑通常没有问题,但应该让经理检查是否合适。

在添加注释前,首先考虑能否通过修订代码来避免使用注释。例如,重命名变量、函数和类,重新排列代码步骤的顺序,引入完好命名的中间变量等。

假想某人正在阅读你的代码。如果有一些不太明显的微妙之处,就应当加上注释。

不要在代码中加入自己姓名的缩写。源代码控制解决方案会自动跟踪这类信息。

如果处理不太明显的 API,应在解释 API 的地方包含对 API 文档的引用。

更新代码时记得更新注释。如果代码的文档中充斥着错误信息,会让人非常困惑。

如果使用注释将某个函数分为多节,考虑这个函数能否分解为多个更小的函数。

分解

分解(decomposition)指将代码分为小段。如果打开一个源代码文件,发现一个有 300 行的函数,其中有大量堪套的代码块,在编程的世界里没有什么比这更令人恐惧了。理想状况下,每个函数或方法都应该只完成一个任务。任何非常复杂的子任务都应该分解为独立的函数或方法。例如,如果有人问你某个方法做什么,你回答“首先做A,然后做 B,最后,如果满足条件 C,那么做 D,和否则做 E”,就应该将该方法分割为辅助方法A、B、C、D、E。

这种分解并不精密。某些程序员认为,函数的长度不该超过一页,这或许是一个很好的经验法则,但有时,某个只有 1/4 页的代码段也需要分解。另一个经验法则是只查看代码的格式,而不阅读其实际内容,代码在任何区域都不应该显得太拥挤。例如,图 3-2 和图 3-3 被故意弄模糊了,看不清内容。但显然,图 3-3 中代码的分解优于图 3-2。

通过重构分解

喝口咖啡,进入编程状态,开始飞快地编写代码,代码的行为确实符合预期,但远远谈不上优雅。每个程序员都常常这么做。在某个项目中,有时会在短时间内编写大量代码,此时效率最高。在修改代码的过程中,也会得到大量代码。当有新的要求或者修订 bug 时,会对现有的代码进行少量改动。计算机术语 cmuft 就是指逐渐累积少量的代码,最终把曾经优雅的代码变成一堆补丁和特例。重构(refactoring)指重新构建代码的结构。下面给出了一些可用来重构代码的技术,更全面的内容请参考附录B列出的重构书籍。

增强抽象的技术

。 封装字段: 将字段设置为取有,使用获取器和设置器方法访问它们。

。 让类型通用: 创建更通用的类型,以更好地共享代码。

分割代码以使其更合理的技术

*。 提取方法: 将大方法的一部分转换成便于理解的新方法。

。 提取类: 将现有类的部分代码转移到新类中。

e 增强代码名称和位置的技巧

,。 移动方法或字段: 移到更合适的类或源文件中。

。重命名方法或字段: 改为更能体现其作用的名称。

。上移(pull up): 在 OOP 中,移到基类中。

。下移(push dowm): 在 OOP 中,移到派生类中。

通过设计来分解

如果使用模块分解,可将以后编写的部分代码放在模块、方法或函数中,程序通常不会像把全部功能放在一起的代码那样密集,结构也更合理。当然,仍然建议在编写代码之前设计程序。

命名

编译器有几个命名规则:

名称不能以数字开头(例如 9to5)。

包含两个下划线的名称(例如 my_name)是保留名称,不应当使用。

以下划线开头(例如 Name 或 Name)的名称是保留名称,不应当使用。

另外,名称可帮助程序员理解程序的各个元素。程序员不应当在程序中使用含糊或不合适的名称。

选择恰当的名称

变量、方法、函数或类的名称应能精确描述其目的。名称还可表达额外的信息,例如类型或者特定用法。当然,真正的考验是其他程序员是否理解某个名称表达的意思。

命名并没有固定的规则,但组织可能制定命名规则。然而,有些名称基本上是不恰当的。表 3-1 显示了一些适当的名称和不当的名称。

适当的名称不当的名称
sourceName、destinationName 区别两个对象thing1、thing2 太笼统
gSettings 表明全局身份globalUserSpecificSettingsAndPreferences 太长
mNameCounter 表明了数据成员身份mNC 太简单,太模糊
calculateMarigoldOffset() 简单,明确doAction() 太宽泛,不准确
mTypeString 心悦目typeSTR256 只有计算机才会喜欢的名称
mWelshRarebit 好的内部玩笑mIHateLarry 不恰当的内部玩笑
errorMessage 描述性名称String 非描述性名称
SourceFile、 destinationFile 无缩写srcFile、 dstFile 缩写

命名约定

选择名称通常不需要太多的思考和创造力。许多情况下,可使用标准的命名技术。下面给出可使用标准名称的数据类型 。

\1. 计数器

以前编程时,代码可能把变量“i”用作计数器。程序员习惯把i 和 j 分别用作计数器和内部循环计数器。然而要小心嵌套循环。当想表示“第 j 个”元素时,经常会错误地使用“第 i 个”元素。使用二维数据时,与使用i和j 相比, 将 row 和 column 用作索引会更容易。 有些程序员更喜欢使用 outerLoopIndex 和 innerLoopIndex等计数器,一些程序员甚至不赞成把i和j 用作循环计数器。

  1. 前绥

许多程序员在变量名称的开头用一个字母提供与变量的类型或用法有关的信息。然而,许多程序员并不赞成使用前缀,因为这会使相关代码在将来难以维护。例如,如果某个成员变量从静态变为非静态,这意味着所有用到这个名称的地方都要修改。这通常非常耗时,因此大多数程序员不会重命名这个变量。随着代码的演变,变量的声明变了,但是名称没有变。结果是名称给出了虚假的语义,实际上这个语义是错误的。当然,通常别无选择,只能遵循公司的约定。表 3-2 显示了一些可用的前缀。

\3. 匈牙利表示法

匈牙利表示法是关于变量和数据成员的命名约定, 在 Microsoft Windows 程序员中很流行。其基本思想是使用更详细的前缀而不是一个字母(例如 m)表示附加信息。下面这行代码显示了匈牙利表示法的用法;

char* pszName; // psz means "pointer to a nul1-terminated string"

术语“匈牙利表示法”源于其发明者 Charles Simonyi 是匈牙利人。也有人认为这准确地反映了一个事实;使用名牙利表示法的程序好像是用外语编写的。为此,一些程序员不喜欢匈牙利表示法。本书使用前缀,而不使用匈牙利表示法。合理命名的变量不需要前绥以外的附加上下文信息,例如,用 mName 命名数据成员就足够了。

注意:

好的名称会传递与用途有关的信息,而不会使代码难以阅读。

\4. 获取器和设置器

如果类包含了数据成员,例如 mStatus,习惯上会通过获取器 getStatus0和设置器 setStatus()访问这个成员。要访问布尔数据成员,通常将 is(而非 get)用作前缀,例如 isRunning0。C++语言并未指定如何命名这些方法,但组织可能会采用这种命名形式或类似的形式。

大写

在代码中大写名称有多种不同的方法。 与编码风格的大多数元素类似, 最重要的是团队将某个方法正式化,且所有成员都采用这种方法。如果某些程序员用全小写字母命名类,并用下划线表示空格(priority queue),而另一些程序员将每个单词的首字母大写(PriorityQueue),代码将乱成一团。变量和数据成员几乎总以小写字母开头,并用下划线(my_queue)或大写字母 myQueue)分隔单词。在 C++中,函数和方法通常将首字母大写,但是,本书采用小写风格的函数和方法, 把它们与类名区别开来。 大写字母可用于为类和数据成员名指明单词的边界。

\6. 把常量放到名称空间中

假定编写一个带图形用户界面的程序。这个程序有几个菜单,包括 File、Edit 和 Help。用常量代表每个菜单的ID。kHelp 是代表 Help 菜单 ID 的一个好名字。

名称 kHelp 一直运行良好,直到有一天在主窗口上添加了一个 Help 按钮。还需要一个常量来代表 Help 按钮的ID,但是 kHelp 已经被使用了 。

在此情况下, 建议将常量放到不同的名称空间中, 名称空间参见第 1 章。可以创建两个名称空间:Menu 和 Button。每个名称空间中都有一个kHelp 常量,其用法为Menu:kHelp 和 Button::kHelp。另一个更好的方法是使用枚举器,参见第 1 章。

C++语言允许执行各种非常难懂的操作。看看下面的古怪代码:

i++ + ++i

这行代码很难便,但更重要的是,C++标准没有定义它的行为。问题在于 it+使用了 i 的值,还递增了i 的值。C++标准没有说明什么时候递增其值,这个副作用(递增)只有在“;”之后才能看到,但是编译器执行到这-行时,可以在任意点执行递增。无法知道哪个i 值会用于 it+部分,在不同的编译器和平台上执行这行代码,会得到不同的值。如果你的编译器与 Ct+17 编译器不兼容,下面的示例也具有不确定的行为,应该避免使用。在 C++17 中,它的行为是确定的:首先递增,然后在 afi中用作索引。a[i] = ++i;在使用 C++语言提供的强大功能时,一定要考虑如何以良好的(而不是丑陋的)风格使用语言特性。

使用常量

不良代码经常乱用“魔法数字”。在一些函数中,代码可能使用 2.718 28,为什么是 2.718 28 呢? 这个值有什么含义? 具有数学背景的人会发现,这代表 e 的近似值,但多数人不知道这一点。C++语言提供了常量,可以把一个符号名称赋予某个不变的值(例如 2.718 28):

const double kapproximationForE = 2.71828182845904523536;

使用引用代替指针

关于大括号对齐的争论

设计专业的 C++程序

在编写应用程序代码之前,应该设计程序。使用什么数据结构? 编写哪些类? 在团队中编程时,规划尤其重要。设想这样的情形: 你坐下来编写程序,却不知道还有哪些同事在编写同一程序,这就需要规划! 本章将学习如何利用专业的 C++方法进行 C++设计。

尽管设计很重要, 但它可能是误解最多的、没有充分利用的软件工程过程。程序员经常没有清晰地规划好,就一头扎入应用程序: 他们一边编写代码,一边设计。这种方法可能导致仿人费解的、极度复杂的设计,开发、调试和维护任务也更加困难。

在项目开始阶段花费额外的时间进行设计,实际上可缩短项目周期,尽管从直觉上讲并不是这样。

程序设计概述

在启动新程序(或已有程序的新功能)时,第一步是分析需求,包括与利益相关方(stakeholden进行商讨。分析阶段的重要输出是“功能需求”文档,描述新代码段到底要做什么,但它不解释如何做。需求分析后,也可能得到“非功能需求”文档,其中描述最终系统是什么,而非做什么。非功能需求的例子有: 系统必须是安全的、可扩展的,还能满足特定性能标准等。

收集了所有需求后,将可以启动项目的设计阶段。程序设计(或软件设计)是为了满足程序的所有功能和非功能需求而实现的结构规范。非正式地讲,设计就是规划如何编写程序。通常应该以设计文档的形式写出设计。

尽管每个公司或项目都有自己的设计文档格式,但大多数设计文档的常见布局基本类似,包括两个主要部分:

将总的程序分为子系统,包括子系统之间的界面和依赖关系、子系统之间的数据流、每个子系统的输入输出和通用线程模型。每个子系统的详情,包括类的细分、类的层次结构、数据结构、算法、有具体的线程模型和错误处理的细节。

设计文档通常包括图和表格,以显示子系统交互关系和类层次结构。统一建模语言UML是图的行业标准,用于绘制此类图以及后续章节中介绍的图(可参见附录 D 来简单了解 UML 语法)。也就是说,设计文档的精确格式不如设计思考过程重要。

注意

设计的关键是在编写程序之前进行思考。

通常在编写代码之前,应该尽可能使设计趋于完善。设计应该提供一个方案图,任何理智的程序员都能够 .遵循它,实现应用程序。当然,一旦开始编写代码,遇到以前没有想到的问题,就需要修改设计。软件工程过程可灵活地进行这种修改。敏捷软件工程方法 Secrum 是此类迭代过程的一个示例, 根据这个方法以循环方式(称为 sprinb开发应用程序。在每个 sprint 中,都可以修改设计,并考虑新需求。第 24 章将详细讲述各种软件工程过程模型。

程序设计的重要性

为尽快开始编程,很容易忽略分析和设计阶段,或者只是草率地进行设计。这一点儿也不像看着代码编译并运行时那种工作取得了进展的感觉。当或多或少地了解了如何构建程序时,完成正式的设计或写下功能需求似乎是在浪费时间。此外,编写设计文档并不像编写代码那么有趣。如果打算整天编写文档,就好像不是当程序员! 我们也是程序员,我们理解在一开始就编写代码的诱惑,有时我们也会这么做。但是,这种做法极可能出问题,除非项目非常简单。如果在实现之前没有进行设计,能否成功取决于编程经验、对常用设计模式的精通程度,还取决于对 C++、问题域和需求的理解程度。

如果你所在团队的每个成员都处理项目的不同部分,那么必须编写一个所有团队成员都遵循的设计文档。设计文档可以帮助新手快速了解项目的设计。为理解程序设计的重要性,请想象要在一小块土地上修建一栋住宅。施工人员来了后,你要求看设计图,“什么设计图? ”他回答,“我知道我在做什么,我不需要提前规划每个细节。两层的住宅? 没问题一 我几个月前刚建了一栋一层的住宅一一 我就用这个模型开始工作。”

假定你放下心头的疑惑,允许施工人员开始修建。几个月后,你发现管道露在墙外,而不是在里面。你向施工人员询问这一异常现象时,他说,“噢,我忘了在墙体中给管道预留位置了。使用新的石膏板技术让我太激动, 我忘了这回事。但是管道在外面也能正常工作,功能才是最重要的。 ”你开始怀疑他的做法,但是仍然允许他继续修建,而不是做出更好的决定。

当你第一次查看已竣工的建筑时,发现厨房少了一个水池。施工人员道歉说:“当厨房完工三分之二时, 我们意识到没有地方放水池了,我们在隔壁添加了一间独立水池房,而不是从头开始,这个水池也能用,对吧? ”如果将施工人员的借口放到软件行业,听着熟悉吗? 就像将管道放到住宅外面一样,你在解决问题时使用过“了丑陋”方案吗? 例如,你忘了对多个线程共享的队列数据结构加锁。当意识到这个问题时,你决定在用到队列的所有地方手动加锁。你觉得:“虽然这很丑陋,但并不影响运行。 ”确实如此,但是某个新加入项目的员工可能假定这个数据结构内建有锁,没有确保在访问共享数据时实现互斥,导致竞态条件错误,三个星期之后才找到这个问题。注意加锁问题只是一个丑陋解决方法的示例。显然,专业的 C++程序员永远不会在每个队列访问中手动加锁,而是在队列类中直接加锁,或者让队列类在不使用锁的情况下以线程安全方式运行。

在编写代码之前进行规范的设计,有助于判断如何将所有内容组合在一起。住宅的设计图可以显示房间之间的关系和结合方式,以满足住宅的要求,与此类似,程序的设计也显示了程序子系统之间的关系和配合方式以满足软件的需求。如果没有设计规划,可能会漏掉子系统之间的联系、重用或共享信息,以及失去用最简单方法完成任务的可能性。如果没有“设计宏图”",可能会在某个实现细节上钻牛角尖,以至于忘记整体结构和目标。此外,设计可提供编写好的文档,供所有项目成员参考。如果使用的欠代过程类似于前述的 Scrum 方法,应该确保在每个循环中都更新设计文档。

如果前面的类比还没有让你下决心在编写代码前进行设计, 这里的一个示例直接编写代码,导致优化失败。假定编写一个国际象棋程序,你不想在编程前设计整个程序,而是决定从最简单的部分开始,缓慢过渡到较难的部分。按照面向对象的方法(参见第 1 章,详见第 5 章),你决定用类建立棋子模型。兵是最小的棋子,因此选择从这里开始。在考虑兵的特性和行为之后,编写了一个具有一些属性和行为的类,图 4-1 显示了 UML类图。

在这里插入图片描述

由于直接编写代码,因此没有生成类图。然而,此时你开始怀疑是不是做错了什么。象和兵有些相似,实际上,它们的属性完全相同,并且有很多共同的行为。尽管象和兵的移动行为有不同的实现方式,但两个棋子都需要移动。如果在编写代码之前进行了设计,就会意识到不同的棋子实际上非常相似,并找到一次性编写通用功能的方法。第 5 章讲述与此相关的面向对象设计技术。

此外,象棋棋子的某些特征取决于程序的其他子系统。例如,如果不知道棋盘的模型,在棋子类中就无法准确表示棋子的位置。另外也可能这样设计程序,让棋盘以某种方式管理棋子,这样棋子就不需要知道自身的位置。无论是哪种情况,在设计棋盘之前就在棋子类中编写位置代码都会出问题。此外,如果不给出程序的用户界面,如何编写棋子的绘制方法? 是使用图形还是基于文本呢? 棋盘应该是什么样子的? 问题在于,程序的子系统并不是孤立存在的一一 子系统彼此有联系。大部分设计工作都用来判断和定义这些关系。

C++设计的特点

在使用 C++进行设计时,需要考虑 C++语言的一些性质,C++具有庞大的功能集。它几乎是 C 语言的完整超集此外还有类、对象、运算符重载、异常、模板和其他功能。由于该语言非常庞大,使设计成为一项令人生晨的任务。C++是一门面向对象语言。这意味着设计应该包含类层次结构、类接口和对象交互。这种设计类型与传统的 C 和其他过程式语言的设计完全不同。第 5 章重点介绍 C++面向对象设计。

C++有许多设计通用的、可重用代码的工具。除了基本的类和继承之外,还可以使用其他语言工具进行高效的设计,如模板和运算符重载。第 6 章将详细讨论可重用代码的设计技术。C++提供了一个有用的标准库,包含字符串类、IO 工具、许多常见的数据结构和算法。所有这些都便于 C++代码的编写。C++语言提供了许多设计模式或解决问题的通用方法。考虑到以上问题,完成 C++程序的设计并非易事。笔者曾经花费数天时间在纸上勾画设计想法,然后把它们探掉,写出更多想法,又控掉,如此反复。有时这个过程是有效的,几天或几星期后会得到整洁高效的设计。有时这个过程令人浊丧,得不到任何结果,但不是一无所获。如果必须再次实现已经出错的设计,很可能浪费更多的时间。重要的是清楚地认识到是否取得真正意义上的进展。如果发现无法继续,可采用以下方法:寻求帮助。请教同事、顾问,或者查阅书本、新闻组或 Web 页面。做一会儿别的事情。稍后回来进行设计。做出决定并继续前进。即使这并非理想方案,也要做出决定并用来工作。不适当的选择会很快表现出来。然而,它可能变成一个可以接受的方法,因为或许没有清晰的方法能完成想要的设计。如果某个方案是唯一满足要求的可行策略,有时不得不接受这个“丑陋的”方案。无论做了什么决定,都应该用文档记录这个决定,这样自己或其他人在以后就可以知道为什么做这样的决定。这包括记录被拒绝的设计,并记录拒绝的原因。

注意:

记住,优秀的设计难能可责,获取这样的设计需要实践。不要期望一夜之间成长为专家,掌握 C++设计比C++编码更难。

C++设计的两个原则

抽象

与现实事物进行类比,将最便于理解“抽象”原则。电视是一种大多数家庭都有的简单科技产品。读者很熟悉其功能:可将其打开或关闭、调换频道、调节音量,还可以添加附属组件,如扬声器、数字视频录像机和蓝光播放器。然而,你能解释这个黑盒子的工作原理吗? 也就是说,知道电视机如何从空中或电缆中接收信号、转换信号并在屏幕上显示吗? 大多数人肯定解释不了电视机的工作原理,但可以使用它。这是由于电视机明确地将内部的实现与外部的接口分离开来。我们通过接口与电视机进行交互: 开关、频道变换器和音量控制器。我们不知道也不关心电视机的工作原理,我们不关心它是使用了阴极射线管技术还是其他技术在屏幕上生成图像,这无关紧要,因为不会影响接口。

\1. 抽象的作用

在软件中也有类似的抽象原则。可使用代码而不必了解底层的实现。在此有一个简单示例,程序可调用在头文件中声明的 sqrt()函数,而不需要知道这个函数使用什么算法求平方根。实际上,平方根计算的底层实现可能因库版本而异; 但只要接口不变,函数调用就可以照常运行。抽象原则也可以扩展到类。第 1 章已经介绍过,可使用 ostream 类的 cout 对象将数据传输到标准输出

cout << "This call will display this line of text" << endl;

在这行代码中, 使用 cout 插入运算符(<<)的已经编写好的接口输出了一个字符数组。然而, 不需要知道 cout如何将文本输出到用户屏幕,只需要了解公有接口。cout 的底层实现可随意改动,只要公开的行为和接口保持不变即可。

\2. 在设计中使用抽象

应该设计函数和类,使自己和其他程序员可以使用它们,而不需要知道(或依赖)底层的实现。为说明公开实现和在接口后隐藏实现的不同,再次考虑前面的国际象棋程序。假定使用一个指向 ChessPiece 对象的二维指针数组实现象棋的棋盘。可以这样声明并使用棋盘:

ChessPiece* chessBoard[8][8];
....
chessBoard[0][0] = new Rook();

然而,这种方法没有用到抽象概念。每个使用象棋棋盘的程序员都知道这是一个二维数组。将该实现转换为其他类型(如一维矢量数组,大小为 64)比较难,因为需要改变整个程序中每一处用到棋盘的代码。棋盘的每个使用者也必须恰当地管理内存。在此没有将实现与接口分开。更好的方法是将象棋棋盘建立为类。这样就可以公开接口,并隐藏底层的实现细节。下面给出 ChessBoard类的示例:

class ChessBoard
{
	public:
		 This example omits constructors,destructor,and assignment operator.
    	void setPieceAt(size_t x,size_t y,ChessPiece* piece);
    	ChessPiece* getPieceAt(size_t x,size_t y);
    	bool isEmpty(size_t x,size_t y) const;
    private:
    	//This example	omits data members
};

注意,该接口并不决定底层实现方式。ChessBoard 可以是一个二维数组,但是接口对此并没有要求。改变实现并不需要改变接口。此外,这个实现还可提供更多功能,如范围检测。

从这个示例可以了解到,抽象是 C++程序设计中的重要技术。第 5 章将详细讲述抽象和面向对象设计,第8 和 9 章讲述与编写自己的类有关的所有细节。

重用

C++设计的第二个原则是重用。用现实世界做类比同样有助于理解这个概念。假定你放弃了编程生涯,而选择自己更喜欢的面包师工作。第一天,面包师主管让你烤饼干。为完成任务,你找到了巧克力饼干的配方混合原料,在饼干盘上让饼干成型,并将盘子放入烤箱。面包房主管对结果感到十分满意。现在,很明显,你没有自己做一个烤箱来烘烤饼干,也没有亲自制作黄油、磨制面粉、制作巧克力片。你可能觉得这匪夷所思:“这还用做? ”如果你真的是一位厨师,当然是这样; 但如果你是一位编写烘焙模拟游戏的程序员,又会怎样? 在此情况下,你不希望编写程序的全部组件,从巧克力片到烤箱;而是查找可重用的代码以节约时间,或许同事编写了一个烹饪模拟程序,其中有很好的烤箱代码。或许这些代码并不能完成你需要的所有操作,但你可以修改这些代码,并添加必要的功能。另一件你认为理所当然的事情是,你采用饼干的某个配方而不是自己做一个配方,这也是不言而喻的。然而,在 C++程序中,这并不是不言而喻的。尽管在 C++中不断涌现处理问题的标准方法,但许多程序员仍然在每个设计中无谓地重造这些策略。使用已有代码的思想并非首次出现。使用 cout 输出,就已经在重用代码了。你并没有编写将数据输出到屏幕的代码,而使用已有的 cout 实现完成这项任务。遗憾的是,并非所有程序员都利用已有的代码。设计时应该考虑已有的代码,并在适当时重用它们。

\1. 编写可重用的代码

重用的设计思想适用于自己编写和使用的代码。应该设计程序,以重用类、算法和数据结构。自己和同事应能在当前项目和今后的项目中重用这些组件。通常,应该避免设计只适用于当前情况的特定代码。在 C++中,模板是一种编写多用途代码的语言技术。下例给出一个模板化的数据结构。如果在此之前没有见过这种语法,不要着急! 第 12 章将深入讲解这一语法。这里编写了一个可用于任何类型的二维棋盘游戏(例如国际象棋或西洋跳棋)的泛型 GameBoard 模板,而不像前面那样编写一个存储 ChessPiece 的特定 ChessBoard 类。只需要修改类的声明, 在接口中将棋子当作模板参数而不是固定类型。这个模板如下所示,

template<typename PieceType>
class GameBoard
{
	public:
    	// This example omits constructors,destructor,and assignment operator.
		void setPieceAt(size_t x,size_t y,PieceType* piece);
    	PieceType* getPieceAt(size_t x,size_t y);
    	bool isEmpty(size_t x,size_t y) const;
    private:
    	//This example omits data members
};

在接口中完成如上简单修改后,现在有了一个可用于任何二维棋盘游戏的泛型游戏棋盘类。尽管代码的变动很简单,但在设计阶段做这样的决定非常重要,以便能有效地实现代码。第6 章将讲述设计可重用代码的更多细节。

\2. 重用设计

学习 C++语言与成为优秀的 C++程序员是两码事。如果你坐下来,阅读 C++标准,记住每个事实,那么你对 C++的了解程度将与其他人差不多。但只有分析代码,并编程自己的程序,积累了一定经验后,才可能成为优秀的程序员。原因在于,C++语法以原始形式定义了该语言的作用,但并未指定每项功能的使用方式。如面包师示例所示,重新发明每道菜的配方是可笑的。然而,程序员在设计期间却常犯类似的错误。他们不是使用已有的“配方”或模式,而是在每次设计程序时都重造这些技术。随着 C++语言使用经验的增加,C++程序员自己总结出使用该语言功能的方式。C++社区通常已经构建起利用该语言的标准方式,一些方式是正规的,一些则不正规。本书将指出该语言的可重用模式,称为设计技术或设计模式。另外,第 28 和 29 章将专门讲解设计技术和模式。你可能已经熟悉其中的一些模式,这些只是平日里司空见惯的解决方案的正式化产物。其他方案描述你在过去遇到的问题的新解决方案。还有一些则以全新方式思考程序的结构。

例如, 假定要设计一个国际象棋程序: 使用一个 ErrorLogger 对象将不同组件发生的所有错误都按顺序写入一个日志文件。当试着设计 ErrorLogger 类时,你意识到只想在一个程序中有一个 ErorLogger 实例。还要使程序的多个组件都能使用这个 ErrorLogger 实例,即所有组件都想要使用同一个 ErrorLogger 服务。实现此类服务机制的一个标准策略是使用注入依赖(dependency injection)。使用注入依赖时,为每个服务创建一个接口,并将组件需要的接口注入组件。因此,此时良好的设计应当使用“依赖注入”模式。你必须熟悉这些模式和技术,根据特定设计问题选择正确的解决方案。在 C++中,还可以使用更多技术和模式。详细讲述设计模式和技术超出了本书的范围,如果读者对此感兴趣,可以参阅附录 B 给出的建议。

重用代码

经验丰富的 C++程序员绝不会完全从零开始启动一个项目。他们会利用各种资源提供的代码,如标准模板库、开放源代码库、他们公司拥有的专用代码和以前项目的代码。应该在设计中大胆地重用代码。为最大限度地遵循这一原则,应该理解可重用代码的类型以及与重用代码相关的一些权衡。

关于术语的说明

在分析代码重用的优缺点之前,有必要指出涉及的术语,并将重用代码分类。有三种可以重用的代码:

过去编写的代码

同事编写的代码

当前组织或公司以外的第三方编写的代码

所使用的代码可通过以下几种方式来构建:

独立的函数或类 ”当重用自己或同事的代码时,通常会遇到这种类型。库 ”库是用于完成特定任务(例如解析 XML)或者针对特定领域(如密码系统)的代码集合。 在库中经常可以找到其他许多功能,如线程和同步支持、网络和图像。框架(Framework) 框架是代码的集合,围绕框架设计程序。例如,微软基础类(Microsoft Foundation, Classes,MFC)提供了在 Microsoft Windows 中创建图形用户界面应用程序的框架。框架通常指定了程序的结构。

注意:

程序使用库,但会适应框架。库提供了特定功能,而框架是程序设计和结构的基础。应用程序编程接口API是另一个经常出现的术语。API 是库或代码为特定目的提供的接口。例如,程序员经常会提到套接字 API,这指的是套接字联网库的公开接口,而不是库本身。

注意:

尽管人们将库以及API 互换使用,但二者并不是等价的。库指的是“实现”,而 API 指的是“库的公开接D” 。为简洁起见,本章剩余部分用术语“库”表示任何可重用的代码,它实际上可能是库、框架,或是同事编写的随机函数集合。

\1. 重用代码的优点

重用代码可以给程序员和项目带来极大好处:你可能不知道如何编写所需的代码,或者抽不出时间来编写代码。的确要编写处理格式化输入输出的代码吗? 当然不需要,这正是使用标准 C++ IO 流的原因。重用的应用程序组件不需要设计,从而简化了设计。 重用的代码通常不需要调试。一般可以认为库代码是没有 bug 的,因为它们已通过测试,并得到广泛使用。相对于初次编写的代码,库可以处理更多错误情况。在项目开始时或许会忘记隐藏的错误或边缘情况,以后则要花时间修正这些问题。重用的库代码通常经过广泛的测试,之前已经被许多程序员使用过,因此可认为它能正确处理大多数错误。e库通常可以检测用户的错误输入。如果发现对于当前状况无效或不适当的请求,通常会给出正确的错误提示。例如 如果请求查找数据库中不存在的记录,或者在没有打开的数据库中读取记录,库都会采取得当的措施。由某个领域的专家编写的重用代码比自己为这个领域编写的代码安全。例如,如果不是安全领域的专家,就不应该试着编写安全代码。如果程序需要安全代码或加密代码,就应该使用库。如果在看似细小的细节上犯了错误,很可能会危及整个程序(甚至整个系统)的安全。 库代码会持续改进。如果重用这些代码,不需要自己动手就可以享受这些改进带来的好处。实际上,如果库的作者恰当地将接口和实现分开,通过升级库版本就可以部受到这些好处,并不需要改变与库的交互方式。良好的更新会修改底层的实现,而不会修改接口。

\2. 重用代码的缺点

遗憾的是,重用代码也有一些缺点:当只使用自己编写的代码时,能完全理解代码的运行方式。当使用并非由自己编写的库时,在使用之前必须花时间理解接口和正确的用法。在项目开始时,这些额外的时间会拖延初始的设计和编码。”当编写自己的代码时,代码的功能正是所需要的。库代码提供的功能与需要的功能未必完全吻合。即使库代码提供的功能正是所需要的,其性能也未必符合要求。一般来说,库代码的性能可能不太好,不太适用于特定场合,或者根本没有相应的文档记录。使用库代码就像打开支持问题的潘多拉魔盒。如果发现库中存在 bug, 该怎么办? 通常无法获取源代码,因此即使想修正这个问题也没办法。如果已经花费大量的时间来学习这个库的接口并使用这个库,可能不想放弃,但很难说服库的开发人员按你的时间安排修正这个bug。此外,如果使用第三方的库,但库的开发人员停止对这个库的支持,而产品依赖这个库,该怎么办? 在决定使用某个无法获取源代码的库之前,应该仔细考虑这个问题。

除支持问题外,库还涉及许可证问题,涉及的问题包括公开源代码、再分发费用(通常称为二进制许可证费用)、版权归属和开发许可证。在使用任何库之前都应该仔细检查许可证问题。例如,某些开放源代码库要求你也公开源代码。重用代码需要考虑的另一个问题是跨平台可移植性。要编写跨平台的应用程序,务必使用可移植的库。 重用代码要求可靠的供应者,必须信赖编写代码的人,认为他的工作非常出色。某些人喜欢控制项目的方方面面,包括每一行源代码。库版本的升级可能会引发问题。升级可能引入 bug,让产品出现致命问题。与性能有关的升级在某些情况下可能会优化性能,但是在特定的情况下可能会使性能恶化。使用纯粹的二进制库时,将编译器升级为新版本会导致问题。只有当库供应者提供与你的新编译器版本兼容的二进制库时,你才能升级编译器。

熟悉了重用代码的术语和优缺点后,就可以决定是否重用代码。通常,这个决定是显而易见的。例如,如果想要用 C++在 Microsoft Windows 上编写图形用户界面(GUD,应该使用 MFC(Microsoft Foundation Class)或Qt 等框架。你可能不知道如何在 Windows 上编写创建 GUI 的底层代码,更重要的是不想浪费时间去学习。在此情况下使用框架可以节省数年的时间。然而,有时情况并不明显。例如,如果不熟悉某个库或框架,并且只需要其中某个简单的数据结构,那就不值得花时间去学习整个框架来重用某个只需要花费数天就能编写出来的组件。总之,这个决定是根据特定的需求做出的选择。通常是在自己编写代码所花时间和查找库并了解如何使用库来解决问题所使用时间之间的权衡。应该针对具体情况,仔细考虑前面列出的优缺点,并判断哪些因素是最重要的。最后,可随时改变想法,如果正确处理了抽象,这并不需要太多的工作量。

重用代码的策略

当使用库、框架以及同事或自己的代码时,应该记住一些指导方针。

花点时间熟悉代码,对于理解其功能和限制因素而言都很重要。可从文档、公开的接口或API 开始,理想情况下,这样做足以理解代码的使用方式。然而,如果库未将接口和实现明确分离,可能还要研究源代码。此外,还可与其他使用过这些代码或能解释这些代码的程序员交流。首先应该理解基本功能。如果是库,那么该库可提供哪些行为? 如果是框架,代码如何适应这个框架? 应该编写哪些类的子类? 需要亲自编写哪些代码? 还应该根据代码的类型考虑特定的问题。

下面是应记住的一些要点:

对于多线程程序而言,代码安全吗?

库是否要求使用它的代码进行特定的编译器设置? 如有必要,项目可以接受吗?

库或框架需要什么样的初始化调用? 需要什么样的清理?

库或框架依赖于其他哪些库?

如果从某个类继承,应该调用哪个构造函数? 应该重写哪些虚方法?

如果某个调用返回内存指针,调用者还是库负责内存的释放? 如果库对此负责,什么时候释放内存?

强烈建议查看是否可使用智能指针来管理由库分配的内存。智能指针在第 1 章中讨论过。库调用检查哪些错误情况? 此时做出了什么假定? 如何处理错误?如何提醒客户端程序发生了错误?应该避免使用弹出消息框、将消息传递到 stderrcerr 或 stdoutcout 以及终止程序的库。某个调用的全部返回值(按值或按引用)有哪些?所有可能抛出的异常有哪些?

C++标准库

C++程序员使用的最重要的库就是 C++标准库。顾名思义,这个库是 C++标准的一部分,因此任何符合标准的编译器都应该包含这个库。标准库并不是整体式的: 它分为几个完全不同的组件,前面已经用过其中的一些。你甚至可能以为这是核心语言的一部分,第 16~21 章将详细讲述标准库。

\1. C 标准库

由于 C++是 C 的超集,因此整个 C 库仍然有效。其功能包括数学函数,例如 abs0、sqrt和 pow0,以及错误处理辅助程序,例如 assert0和 ermo。此外,C 标准库的一些工具在 C++中仍然有效,例如将字符数组作为字符串操作的 strlen0、strcpy0,以及C 风格的 IO 函数,例如 printf()和 scanf()。

注意:

C++提供了比 C 更好的字符串以及I/O 支持。尽管 C 风格的字符串和JJO 例程在 C++中仍然有效,但应该避免使用它们,而是使用 C++字符串(详见第 2 章)和 IO 流(详见第 13 章)。

设计一个国际象棋程序

本节通过一个简单的国际象棋程序系统介绍 C++程序的设计方法。为提供完整示例,某些步又用到了后面几章讲述的概念。为了解设计过程的概况,可现在就阅读这个示例,也可以在学完后面章节后返回头来学习。

4.6.1 需求

在开始设计前,应该弄清楚对于程序功能和性能的需求。理想情况下,这些需求应该是以需求规范(Cequirements specification)形式给出的文档。国际象棋程序的需求应该包含下列类型的规范,当然实际的需求规范应该比下面的内容更详细,条目更多;

范应该比下面的内容更详细,条目更多;程序支持两个玩家。程序不提供具有人工智能的计算机玩家。”程序提供基于文本的界面。程序以纯文本形式提供棋盘和棋子。玩家通过输入代表位置的数字在棋盘上移动棋子。需求可保证设计的程序能按用户的期望运行。

设计步骤

应按系统的方法设计程序,从一般到特殊。下面的步骤并不一定适用于所有程序,但提供了通用指导方针。在需要时设计应该包含图示和表格。制作图示的行业标准称为 UML(统一建模语言)。有关 UML 的简述,请参阅附录 D。UML 定义了一组标准图示,可用于说明软件设计(如类图、序列图等)。建议使用 UML,至少也要尽量使用类似 UML 的图示。但不一定要严格遵循 UML 语法,因为图示清晰、易于理解,要比语法正确\1. 将程序分割为子系统设计的第一步是将程序分割为通用功能子系统,并指明子系统之间的接口和交互关系。此时不需要考虑特定的数据结构和算法,甚至不需要考虑类。只是试着感受程序不同部分和它们之间的交互关系。可将子系统列在一张表格中,从而表示子系统的高层行为和功能、子系统展现给其他子系统的接口和这个子系统使用的其他子系统接口。建议国际象棋程序使用模型-视图-控制(MVC)模式将数据存储和数据显示明确分离, MVC 模式建立了如下理念: 许多应用程序经常要处理一组数据,处理这些数据上的一个或多个视图,并操作这些数据。在MVC 中,这组数据称为模型,视图是模型的一个特定界面,控制器修改模型,以响应某个事件的代码。MVC的3 个组件在反馈循环中交互操作;动作由控制器处理,控制器会调整模型,把修改返回到视图中。通过这种方式,可很方便地在文本界面和图形用户界面之间切换。表 4-2 是关于国际象棋游戏子系统的表格。

子系统实例功能公开接口使用的接口
GamePlay1开始游戏 控制游戏进度 控制绘图 判断胜方 游戏结束(GamePlay 提供)游戏结束轮流(Player 提供) 绘图 (ChessBoardView 提供)
ChessBoard1存储棋子 检测平局和将死设置棋子 获取棋子游戏结束(GamePlay 提供)
ChessBoardview1绘制相关的棋盘绘制绘制(Chesspieceview 提供)
ChessPiece32移动自身 检测合法移动移动 检测移动获取棋子(ChessBoard 提供) 设置棋子(ChessBoard 提供)
ChessPieceView32绘制相关的棋子绘制
Player2与用户交互: 提醒用户移动,获取用户的移动信息,移动棋子轮流获取棋子(ChessBoard 提供) 移动(ChessPiece 提供) 检测移动(ChessPiece 提供)
ErrorLogger1将错误信息写入日志文件记录错误

如表 4-2 所示,国际象棋游戏的功能子系统包括: GamePlay、ChessBoard、ChessBoardView、ChessPiece、ChessPieceView、Player 和 ErrorLogger。然而,这并不是唯一合理的方式。软件设计与编程本身一样,达到同一个目标有多种不同的方法。并不是所有的方法都是等价的: 有些方法比另一些方法好。然而,经常有几种同样有效的方法。

划分良好的子系统将程序分割为基本功能单元。例如,Player 就是与 ChessBoard、ChessPiece 和 GamePlay明显不同的子系统。将 Player 混合在 GamePlay 子系统中没有任何意义,因为在逻辑上它们是独立的子系统。但其他选择未必这么明显。在MVC 设计中, ChessBoard 和 ChessPiece 子系统是模型部分。ChessBoardView 和 ChessPieceView 是视图部分,Player 是控制器部分。

由于表格无法形象地表示子系统之间的关系,通常会使用图示来表明程序的子系统,在此箭头表示一个子系统对另一子系统的调用。图 4-4 用 UML 用例图显示了国际象棋游戏的各个子系统。

在这里插入图片描述

\2. 选择线程模型

在设计阶段,考虑如何在要编写的算法中将特定的循环编写为多线程显得太早了。但在这个阶段,可以选择程序中高级线程的数目并指定线程的交互方式。高级线程的示例有 UI 线程、音频播放线程、网络通信线程等。在多线程设计中,应该尽可能避免共享数据,这样可使程序更简单、更安全。如果无法避免共享数据,应该指定加锁需求。如果不熟悉多线程程序,或者平台不支持多线程,那么程序应该是单线程的。然而,如果程序有多个不同的任务,每个任务都并行运行,多线程或许是个不错的选择。例如,图形用户界面程序经常让一个线程执行主程序,其他线程等待用户按下按钮或者选择菜单项。多线程程序将在第 23 章讲述。

国际象棋程序只需要一个线程来控制游戏流程。

\3. 指定每个子系统的类层次结构

在这个步骤中决定程序中要编写的类层次结构。国际象棋程序需要一个类层次结构来代表棋子,这个类层次结构如图 4-5 所示。在这个类层次结构中,ChessPiece 泛型类作为抽象基类。ChessPieceView 类也有类似的类层次结构。

在这里插入图片描述

另一个类层次结构用于 ChessBoardView 类,以实现游戏的文本界面或用户图形界面。图 4-6 给出了这个类层次结构, 可以在控制台以文本方式显示棋盘,也可以用2D或3D 图形显示棋盘Player控制器和ChessPieceView类层次结构的各个类也需要类似的类层次结构。

在这里插入图片描述

\4. 指定每个子系统的类、数据结构、算法和模式, 在这个步骤中,需要更大程度地考虑细节,并指定每个子系统的细节,包括指定为每个子系统编写的特定类。可能最后模型中的每个子系统都会成为一个类。这些信息也可以用表 4-3 总结。

子系统数据结构算法模式
GamePlayGamePlay类GamePlay 对象包含一个ChessBoard 对象和两个Player 对象让每个玩家轮流移动
ChessBoardChessBoard类ChessBoard 对象存储 32个棋子的二维表示在每次移动之后检测“胜”或“和”
ChessBoardView抽象超类: ChessBoardView具体子类;ChessBoardViewConsole、ChessBoardViewGUI2D存储与如何绘制棋盘有关的信息绘制棋盘观察者 (Observer)
ChessPiece抽象超类:ChessPiece子类,Rook、Bishop、Knight、King、Pawn 和 Queen每个棋子存储它在棋盘上的位置在棋盘的不同位置查询棋子,判断棋子的移动是否合法
ChessPieceView抽象基类: ChessPieceView子类: RookView、BishopView .…具体子类;RookViewConsole、RookViewGUI2D …存储如何绘制棋子的信息绘制棋子观察者
Player抽象超类:Player 具体子类:PlayerConsole、PlayerGUI2D提示用户移动, 检测移动是否合法, 移动仲裁者(Mediaton)
ErrorLoggerErrorLogger类要记录的消息队列缓存消息, 并将消息写入日志文件依赖注入

设计文档的这部分内容通常提供每个类的实际接口,但这个示例略去了该层次的细节。设计类和选择数据结构、算法和模式都很灵活。应该牢牢记住本章前面讲过的抽象和重用法则。对于抽象而言,关键是将接口与实现分开。首先,从用户的观点确定接口,决定想让组件做什么,然后决定如何选择数据结构和算法,让组件做到这一点。对于重用而言,应该熟悉标准数据结构、算法和模式。此外,一定要熟悉C++标准库代码和公司拥有的专用代码。

子系统处理系统错误处理用户错误
GamePlay如果无法为 ChessBoard 或 Player 分配内存,用ErrorLogger 记录错误, 向用户显示一条消息, 并按正常步骤关闭程序不适用(不是直接的用户界面)
ChessBoard ChessPiece如果无法分配内存,用 ErrorLogger 记录错误并抛出异常不适用(不是直接的用户界面)
ChessBoardView ChessPieceView如果在绘制时出现错误,用 ErrorLogger 记录错误并抛出异常不适用(不是直接的用户界面)
Player如果无法分配内存,用 ErrorLogger 记录错误并抛出异常全面检测用户的移动输入,以确保用户没有离开棋盘;提示用户输入其他信息; 在移动棋子之前检测移动的合法性,如果不合法,提示用户重新移动
ErrorLogger如果无法分配内存,尝试记录错误,通知用户并按正常步骤关闭程序不适用(不是直接的用户界面)

错误处理的通用法则是处理一切。努力想象所有可能的错误情况,如果忘掉了某种可能性,就可能在程序中形成 bug! 不要将任何事情都当作“意外”错误。要考虑到所有可能性:; 内存分配失败、无效用户输入、破盘错误和网络错误,以上只是给出了一些示例。然而,正如国际象棋游戏显示的那样,应将内部错误和用户错误区别对待。例如,用户输入无效移动时不应该使程序终止。第 14 章将深入介绍错误处理。

面向对象设计

过程化的思考方式

过程语言(例如 C)将代码分割为小块,每个小块(理论上)完成单一的任务。如果在 C 中没有过程,所有代码都会集中在 main()中。代码将难以阅读,同事会恼火,这还是最轻的。

计算机并不关心代码是位于 main()中还是被分割成具有描述性名称和注释的小块。过程是一种抽象,它的存在是为了帮助程序员和阅读或维护代码的人。 这个概念建立在一个与程序相关的基本问题之上一 程序的作用是什么? 用语言回答这个问题,就是过程化思考。例如,以下面的答案为起点设计一个股票选择程序: 首先从Internet 获取股价,然后根据特定的指标对数据排序,之后分析已经排序的数据,最后输出建议购买和出售的列表.。 当开始编写代码时, 可能会将脑海中的模型直接转换为 C 函数: retrieveQuotes()、sortQuotes(0、analyzeQuotes()和 outputRecommendations()。尽管 C 将过程表示为“邓数”, 但 C 并非一门函数式语言.术语“函数式(functional)”与“过程式(proceduraD”有很大的不同,指的是类似于 Lisp 的语言,Lisp 使用完全不同的抽象。当程序遵循特定的步骤序列时,过程方法运行良好。然而,在现代的大型应用程序中,很少有线性的事件序列,通常用户可在任何时候执行任何命令。 此外,过程思想对于数据的表示没有任何说明,在前面的示例中,并没有讨论股价实际上是什么。

如果过程模型听起来像处理程序的方法,不要担心。OOP 只是一种更灵活的替代方法,只是一种对软件的思考方法,面向对象编程就会变得十分自然。

面向对象思想

与基于“程序做什么”问题的面向过程方法不同,面向对象方法提出另一个问题: 模拟哪些实际对象? OOP的基本观念是不应该将程序分割为若干任务,而是将其分为自然对象的模型。乍看上去这有些抽象,但用类、组件、属性和行为等术语考虑实际对象时,这一思想就会变得更清晰。

类将对象与其定义区分开来。考虑橘子,长在树上,一般作为美味水果的橘子和某个特定橘子(例如,现在笔者的键盘旁就放着一个往外渗汁的橘子)有所不同。当回答“什么是橘子”时,就是在谈论“橘子”这种水果。所有橘子都是水果,所有橘子都长在树上,所有橘子都是橙色的,所有橘子都有特定的味道。类只是封装了用来定义对象分类的信息。当描述某个特定橘子时,就是在讨论一个对象。所有对象都属于某个特定的类。由于笔者桌子上的对象是-个橘子,所以它属于橘子(Orange)类。因此,它是长在树上的一种水果,它的颜色是中等程度的栖色,并且味道不错。对象是类的一个实例(instance)-一 它拥有一些特征,从而与同一类型的其他事物区分开来。上面的股票选择程序是一个更具体的示例。在 OOP 中,“股价”是一个类,因为它定义了报价这个抽象概念。某个特定的报价(例如“当前 Microsoft 股价”)是一个对象,因为它是这个类的特定实例。

具有 C 背景的程序员,可将类和对象类比为类型和变量。实际上,第 8 章将提到,类的语法与 C 的结构类似。

组件

如果考虑一个复杂的实际对象,例如飞机,很容易看到它由许多小组件(component)组成。其中包括机身、控制器、起落装置、引擎和其他很多部件。对于 OOP 而言,将对象分解为更小组件是一项必备能力,就像将复杂任务分解为较小过程是过程式编程的基础一样。本质上,组件与类相似,但组件更小、更具体。优秀的面向对象程序可能有 Aimplane 类,但是,如果要充分描述飞机,这个类将过于庞大。因此,Aimplane 类只处理许多更容易管理的小组件。每个组件可能还有更小的组件,例如,起落装置是飞机的一个组件,车轮是起落装置的一个组件。

属性

属性将一个对象与其他对象区分开来。回到橘子(Orange)类,所有橘子都定义为橙色的,并具有特定的口味,这两个特征就是属性。所有橘子都具有相同的属性,但属性的值不同。一个橘子可能“美味可口” 但另一个橘子可能“苦涩难吃”。可在类的层次上思考属性。如前所述,所有橘子都是水果,都长在树上。这是水果类的属性,而特定的橙色是由特定的水果对象决定的。类属性由所有的类成员共享,而类的所有对象都有对象属性,但具有不同的值。在股票选择示例中,股价有几个对象属性,包括公司名称、股票代码、当前股价和其他统计数据。属性用来描述对象的特征,回答“为什么这个对象与众不同”的问题。

行为

行为回答两个问题:“对象做什么”和“能对对象做什么”在橘子示例中,橘子本身不会做什么,但是我们可对橘子做一些事情。橘子的行为之一是可供食用。与属性类似,可在类或对象层次上思考行为。几乎所有橘子都会以相同的方式被吃掉,但其他行为未必如此,例如被扔到斜坡上向下滚动,圆橘子与扁圆橘子的行为明显不同。

前面的股票选择示例提供了一些更实际的行为。 以过程方式思考, 该程序的功能之一就是分析股价。以 OOP的方式思考,则股价对象可以自我分析,分析变成股价对象的一个行为。

在面向对象编程中,许多功能性的代码从过程转移到类。通过建立具有某些行为的对象并定义对象的交互方式,OOP 以更丰富的机制将代码和代码操作的数据联系起来。类的行为由类方法实现。

”综合考虑

通过这些概念,可回头分析股票选择程序,并以面向对象的方式重新设计这个程序。前面说过,“股价”类是一个不错的开始。为获取报价表,程序需要股价组的概念,通常称为集合。因此,较好的设计可能使用一个类代表“股价的集合”,这个类由代表单个“股价”的小组件组成。再来说说属性,这个集合类至少有一个属性一 实际接收到的报价表。它可能还有其他附加属性,例如大多数最新检索的确切日期和时间。至于行为,“股价的集合”将从服务器那里获取报价,并提供有序的报价表。这就是“获取报价”行为。股价类具有前面讨论的一些属性-一 名称、代码、当前价格等,此外还具有分析行为。还可能考虑其他行为,如买入和卖出股票。图示通常有助于呈现组件之间的关系。图 5-1 使用 UML 类图语法(附录 D 介绍 UML 语法)来说明一个StockQuoteCollection(股价集合)包含零个或多个 StockQuote(股价)对象,StockQuote 对象属于单个StockQuoteCollection

在这里插入图片描述

生活在对象世界里

当程序员的思想从面向过程转换到面向对象模式时,对于将属性和行为结合到对象,通常会有一种忧然大悟的感觉。某些程序员重新设计正在执行的项目,并要将某些部分作为对象重写。其他程序员可能试着抛开所有代码并重新开始这个项目,将其作为完全的面向对象应用程序。

使用对象开发软件主要有两种方法。对于某些人来说,对象只是代表数据和功能的良好封装,这些程序员在程序中大量使用对象,使代码更容易阅读和维护。采用这种方法的程序员将独立的代码段切除,用对象蔡换它们,就像外科医生给病人植入心脏起捕器一样。这种方法当然没有错,这些人将对象当作在许多情况下有益的工具。程序的某些部分(例如股价)只是“感觉像对象"。这些部分可被分离开来,并用实际术语描述。

另一些程序员彻底采用 OOP 范型,将一切都转换为对象。在他们的心目中,某些对象对应于实际事物,例如橘子或股价,而另一些对象封装了更抽象的概念,例如 sorter 或 undo 对象。理想方法或许介于这两个极端之间。第一个面向对象程序可能实际上只在传统的过程式程序中使用几个对象;, 以后可能全力以赴将一切都作为对象,从表示 int 的类到表示主应用程序的类。随着时间的推移,一定会找到合理的折中方法。

理想方法或许介于这两个极端之间。第一个面向对象程序可能实际上只在传统的过程式程序中使用几个对象;, 以后可能全力以赴将一切都作为对象,从表示 int 的类到表示主应用程序的类。随着时间的推移,一定会找到合理的折中方法。

过度使用对象

设计一个富于创造性的面向对象系统, 将所有细小的事情都转换为对象,常常会惹恼团队中的所有其他人。弗洛伊德曾经说过,有时变量就只是变量。下面就解释这句话的含义。假定设计一款将会畅销的井字游戏,打算完全采用 OOP 方法,因此你坐了下来,喝一杯咖啡,在笔记本上勾画出所需的类和对象。在此类游戏中,通常有一个对象监视游戏的进度,并裁定胜方。为了表示游戏棋盘,需要用 Grid 对象跟踪标记和它们的位置。实际上,表示X或 O 的 Piece 对象是 Grid 的一个组件。等下,退回去! 这种设计打算用一个类代表 X 或 O。这就是过度使用对象的一个例子。用 char 不能代表 X或 0 吗? 另外,用一个枚举类型的二维数组表示 Gird 不是更好吗? Piece 对象只会使代码更复杂。看看表 5-1建议的 Piece(棋子)类。另一方面,深谋远虑的程序员可能认为,尽管当前 Piece 类很小,但使用对象可在将来扩展时不受影响。或许发展下去这会成为一个图形应用程序,用 Piece 类支持绘图行为可能是有用的。其他属性可能是棋子的颜色或者一些判定,用于判断 Piece 是不是最近移动的那枚棋子。

另一种方案是考虑方格(Grid)的状态而不是使用棋子。方格的状态可能是空、X 或 0。为在将来支持图形应用程序,可设计一个抽象超类 State,其具体子类 StateEmpty、StateX 和 StateO 知道如何表示自身。

显然在此不存在唯一正确的答案,关键是在设计应用程序时应该考虑这些问题。记住对象是用来帮助程序员管理代码的,如果使用对象只是为使代码“更加面向对象” 就错了。

过于通用的对象

相对于将不应该确定为对象的事物当作对象,过于通用的对象可能更糟糕。所有的 OOP 学生都以“橘子示例开始-一 这确实是对象,没有疑问。在实际编码中,对象可以非常抽象。许多 OOP 程序都有一个“应用程序对象” 尽管应用程序并不能以物质的形式表现,但用对象表示应用程序仍然是有意义的,因为应用程序本身具有一些属性和行为。

过于通用的对象是根本不代表具体事物的对象。程序员的本意可能是建立一个灵活或可重用的对象,但最终得到一个令人迷惑的对象。例如,考虑一个组织和显示媒体的程序。这个程序可将照片分类,组织数字音乐唱片,还可作为个人日志。将所有事物都当作 Media 对象,并创建一个可容纳所有格式的类,就是一种过分的做法。这个类可能有一个 data 属性,这个 属性包含图像、歌曲或日志项的原始位,具体取决于媒体类型,该类还可能有一个 perform 行为,可正确地绘制图像、播放歌曲或编辑日志项。这个类过于通用的原因在于属性和行为的名称。单词 data 本身几乎没有 意义-一-在此必须使用一个通用词语,因为这个类被过度地扩展到三种完全不同的情况。同理,perform 会在三种不同的情况下执行差别极大的操作。 总之,这种设计过于通用,因为 Media 不是一个特定对象; 无论在用户界面中、在实际中还是在程序员的头脑中,它都不是一个特定对象。当程序员的脑海中的许多想法都用一个对象连接起来时,这个类就过于通用了,如图

作为程序员,必然会遇到这样的情况: 不同的类具有共同的特征,至少看起来彼此有联系。例如,尽管在数字化目录程序中创建一个 Media 对象来代表图像、音乐和文本过于通用,但这些对象确实有共同特征。它们可能都要跟踪最近修改日期和时间,或者都支持删除行为。面向对象的语言提供了许多机制来处理对象之间的这种关系。最坏手的问题是理解这些关系的实质。对象之间的关系主要有两类一 “有一个”(has a)关系和“是一个”(is 9)关系。

5.4.1 “有一个”关系

“有一个”关系或聚合关系的模式是 A 有一个B,, 或者A 包含一个 B。在此类关系中,可认为某个对象是另一个对象的一部分。前面定义的组件通常代表“有一个”关系,因为组件表示组成其他对象的对象。动物园和猴子就是这种关系的一个示例。可以说动物园里有一只猴子,或者说动物园包含一只猴子。在代码中用 Zoo 对象模拟动物园,这个对象有一个 Monkey 组件。考虑用户界面通常有助于理解对象之间的关系。尽管并非所有的 UI 都是(尽管现在大多数是)以 OOP 方式实现的,屏幕上的视觉元素也能很好地转换为对象。UI 关于“有一个”关系的类比就是窗口中包含一个按钮。按钮和窗口是两个明显不同的对象,但又明显有某种联系。由于按钮在窗口中,因此说窗口中有一个按钮。

“是一个”关系(继承)

“是一个”关系是面向对象编程中非常基本的概念,因此有许多名称,包括派生(deriving)、子类(subclass)、扩展(extending)和继承(inheriting)。类模拟了现实世界包含具有属性和行为的对象这一事实,继承模拟了这些对象通常以层次方式来组织这一事实。“是一个”说明了这种层次关系。

基本上,继承的模式是: A 是一个 B,或者 A 实际上与 B 非常相似-一 这可能比较棘手。再次以简单的动物园为例,但假定动物园里除了猴子之外还有其他动物。这句话本身已经建立了关系一 猴子是一种动物。同样,长颈鹿(Giraffe)也是一种动物,袋鼠(Kangaroo)是一种动物,企忽(Penguin)也是一种动物。那又怎么样? 意识到猴子、长颈认、袋鼠和企忽具有某种共性时,继承的魔力就开始显现了。这些共性就是动物的一般特征。对于程序员的启示就是,可定义 Animal 类,用以封装所有动物都有的属性大小、生活区域、食物等)和行为(走动、进食、睡觉)。特定的动物(例如猴子)成为 Animal 的子类,因为猴子包含动物的所有特征。记住,猴子是动物,它还有与众不同的其他特征。图 5-5 显示了动物的继承图示。箭头表明“是一个”关系的方向。

在这里插入图片描述

就像猴子与长颈鹿是不同类型的动物一样,用户界面中通常也有不同类型的按钮。例如,复选框是一个按钮,按钮只是一个可被单击并执行操作的 UI 元素,Checkbox 类通过添加状态(相应的框是否被选中)扩展了Button 类。

当类之间具有“是一个”关系时,目标之一就是将通用功能放入基类(base class),其他类可扩展基类。如果所有子类都有相似或完全相同的代码,就应该考虑将一些代码或全部代码放入基类。这样,可在一个地方完成所需的改动,将来的子类可“免费”获取这些共享的功能。

\1. 继承技术

前面的示例非正式地讲述了继承中使用的一些技术。当生成子类时,程序员有多种方法将某个类与其父类(parent class)、基类或超类(superclass)区分开。可使用多种方法生成子类,生成子类实际上就是完成语句AisaBthat…的过程。

添加功能

派生类可在基类的基础上添加功能。例如,猴子是一种可挂在树上的动物。除了具有动物的所有行为以外,猴子还具有在树间移动的行为,即 Monkey 类有 swingFromTrees()方法,这个行为只存在于 Monkey 类中。

替换功能

派生类可完全替换或重写父类的行为。 例如, 大多数动物都步行, 因此 Animal 类可能拥有模拟步行的 mc行为。但袋鼠是一种通过跳跃而不是步行移动的动物,Animal 基类的其他属性和行为仍然适用,Kangaroo 派类只需要改变 move 行为的运行方式。当然,如果对基类的所有功能都进行蔡换,就可能意味着采用继承的式根本就不正确,除非基类是一个抽象基类。抽象基类会强制每个子类实现未在抽象基类中实现的所有方法无法为抽象基类创建实例,第 10 章将介绍抽象类。

状加属性

除了从基类继承属性以外,派生类还可添加新属性。企禾具有动物的所有属性,此外还有 beak size(鸟吃大小)属性。

蔡换属性

与重写方法类似,C++提供了重写属性的方法。然而,这么做通常是不合适的,因为这会隐藏基类的属性,例如,基类可为具有特定名称的属性指定一个值,而派生类可给该属性指定另一个值。有关“隐藏”的内容,详见第 9 章。不要把蔡换属性的概念与子类具有不同属性值的概念混淆。例如,所有动物都具有表明它们吃什么的 diet 属性,猴子吃香蕉,企丽吃鱼,二者都没有蔡换 diet 属性一 只是赋给属性的值不同而已。

\2. 多态性与代码重用

多态性(Polymorphism)指具有标准属性和方法的对象可互换使用。类定义就像对象和与之交互的代码之间的契约。根据定义,Monkey 对象必须支持 Monkey 类的属性和行为。这个概念也可推广到基类。 由于所有猴子都是动物, 因此所有 Monkey 对象都支持 Animal 类的属性和行为。

多态性是面向对象编程的亮点,因为多态性真正利用了继承所提供的功能。在模拟动物园时,可通过编程遍历动物园中的所有动物,让每个动物都移动一次。由于所有动物都是 Animal 类的成员,因此它们都知道如何移动。某些动物重写了移动行为,但这正是亮点所在一 代码只是告诉每个动物移动,而不知道也不关心是哪种动物。所有动物都按自己的方式移动。

除多态性外,使用继承还有一个原因,通常这只是为了利用现有的代码。例如,如果需要一个具有回声效果的音乐播放类,而同事已经编写了一个播放音乐的类,但没有其他任何效果,此时可扩展这个已有的类,添加新功能。“是一个”关系仍然适用(回声音乐播放器是一个增添了回声效果的音乐播放器),但这些类不打算互换使用。最终得到两个独立的类,用在程序完全不同的部分(或者用于完全不同的程序),只是为了避免做重复的工作,二者才有了关联。

“有一个”与“是一个”的区别

在现实中,区分对象之间的“有一个”与“是一个”关系相当容易。没人会说橘子有一个水果一一桶子是一种水果。在代码中,有时并不会这么明显。

考虑一个代表哈希表的假想类,哈希表是高效地将键映射到值的一种数据结构。例如,保险公司使用Hashtable 类将成员 ID 映射到名称,从而给定一个 ID 就可以方便地找到对应的成员名称。成员 ID 是键,成员名称是值。在实现标准的哈希表时,每个键都有一个值。如果 ID 14534 映射到名称“Kleper Scott"”,就不能再映射到成员名称“Kleper Mami”。在大多数实现中,如果对一个已经有值的键添加第二个值,第一个值就会消失。换句话说,如果 ID 14534 映射到“Kleper, Scott”,然后又将 ID 14534 分配给“Kleper Mami”,那么 Scott 将被遗弃,下面的序列调用了两次假想哈希表的 insert()方法,并给出了每次调用结束后哈希表的内容。

在这里插入图片描述

不难想象类似于哈希表但允许一个键有多个值的数据结构的用法。在保险公司示例中,一个家庭可能有多个名称对应于同一个呈。由于这种数据结构非常类似于哈希表,因此可用某种方式使用哈希表的功能。哈希表的键只能有一个值,但是这个值可以是任意类型。除字符串外,这个值还可以是一个包含多个键值的集合(例如数组或列表)。当向已有 ID 添加新的成员时,可将名称加入集合中。运行方式如下所示:

Collection collection;           // Make a new collection.
collection.insert("Kleper,Scott");// Add a new element to the collection.
hash.insert(12234,collection);   // Insert the collection into the table.

在这里插入图片描述

Collection collection = hash.get(14534);// Retrieve the existing collection.
collection.insert("Kleper, Marni");// Add a new element to the collection.
hash.insert(14534, collection); // Replace the collection with the updated one.

在这里插入图片描述

使用集合而不是字符串有些繁杂,需要大量重复代码。最好在一个单独的类中封装多值功能,可将这个类叫作 MultiHash。MultiHash 类的运行与 Hashtable 类似,只是背后将每个值存储为字符串的集合,而不是单个字符串。很明显,MultiHash 与 Hashtable 有某种联系,因为它仍然使用哈希表存储数据。不明显的是,这是“是-个”关系还是“有一个”关系?

先考虑“是一个”关系。假定 MultiHash 是 Hashtable 的派生类,它必须重写在表中添加项的行为,从而既可创建集合并添加新元素,又可检索已有集合并添加新元素。此外还必须重写检索值的行为。例如,可将给定键的所有值集中到一个字符串中。这好像是一种相当合理的设计。即使派生类重写了基类的所有方法,也仍可在派生类中使用原始行为,从而使用基类的行为。这种方法的 UML 类图如图 5-6 所示。

现在考虑“有一个”关系。MultiHash 属于自己的类,但是包含了 Hashtable 对象。这个类的接口可能与Hashtable 非常相似,但并不需要相同。在幕后,当用户向 MultiHash 添加项时,会将这个项封装到一个集合并送入 Hashtable 对象。这也很合理,如图 5-7 所示。

在这里插入图片描述

在这里插入图片描述

现在考虑“有一个”关系。MultiHash 属于自己的类,但是包含了 Hashtable 对象。这个类的接口可能与Hashtable 非常相似,但并不需要相同。在幕后,当用户向 MultiHash 添加项时,会将这个项封装到一个集合并送入 Hashtable 对象。这也很合理,如图 $-7 所示。

那么,哪个方案是正确的? 没有明确的答案,笔者的一个朋友认为这是“有一个”关系,他编写了一个MultiHash 类供产品使用。主要原因是允许修改公开的接口,而不必考虑维护哈希表的功能。例如,将图 5-7 中的 get 方法改成 getAll,以清楚表明将获取 MultiHash 中某个特定键的所有值。此外,在“有一个”关系中,不需要担心哈希表的功能会渗透。例如,如果 Hashtable 类提供了获取值的总数的方法,只要 MultiHash 不重写这个方法,就可以用这个方法报告集合的项数。这就是说,MultiHash 实际上是一个具有新功能的 Hashtable,这一说法能让人信服,因此应该是“是一个”关系。关键在于有时这两种关系之间的差别很小,需要考虑使用类的方式,还需要考虑所创建的类只是利用了其他类的一些功能,还是在其他类的基础上修改或添加新功能。 表 5-2 给出了关于 MultiHash 的两种方法的支持和反对意见。

是一个有一个
支持的原因基本上,这是具有不同特征的同一抽象 这个类的方法与 Hashtable(几乎)相同MultiHash 可以拥有任何有用的方法,而不需要考虑 Hashtable 拥有什么方法 可不采用 Hashtable 实现方式, 同时不需要改变公开的
反对的原因根据定义,哈希表的一个键对应一个值,将MultiHash 当作哈希表是错误的 MultiHash 将哈希表的两个方法全部重写, 这 Hashtable 未知的或不正确的属性和方法会在某种意义上, MultiHash 通过提出新方法进行了重造 Hashtable 的一些其他属性和方法可能是有用的

反对“是一个”关系的理由在这种情况下非常有力。LSP(Liskov Substitution Principle,里氏蔡换原则)可帮助从“是一个”和“有一个”关系中选择。这个原则指出,你应当能在不改变行为的情况下,用派生类替代基类。将这个原则应用于本例,则表明应当是“有一个”关系,因为你无法在以前使用 Hashtable 的地方使用MultiHash。和否则,行为就会改变。例如,Hashtable 的 insert(方法会删除映射中同一个键的旧值,而 MultiHash不会删除此类值。实际上,根据笔者多年的经验,如果可以选择,建议采用“有一个”关系,而不是“是一个”关系。注意,这里使用 Hashtable 和 MultiHash 说明了“有一个”和“是一个”关系的不同之处。在代码中,建议使用标准 Hashtable 类,而不是自己写一个。C++标准库中提供了 unordered_map 类,用来代替 Hashtable,此外还提供了 unordered_multimap 类,用来代替 MultiHash 类。第 17 章将讨论这两个标准类。

not-a 关系

当考虑类之间的关系时,应该考虑类之间是否真的存在关系。不要把对面向对象设计的热情全部转换为许多不必要的类/子类关系。当实际事物之间存在明显关系,而代码中没有实际关系时,问题就出现了。OO(面向对象)层次结构需要模拟功能关系,而不是人为制造关系。图 5-8 显示的关系作为概念集或层次结构是有意义的,但在代码中并不能代表有意义的关系。

在这里插入图片描述

在这里插入图片描述

避免不必要继承的最好方法是首先给出大概的设计。为每个类和派生类写出计划设置的属性和行为。如果发现某个类没有自己特定的属性或方法,或者某个类的所有属性和方法都被派生类重写,只要这个类不是前面提到的抽象基类,就应该重新考虑设计。

多重继承

到目前为止,所有示例都只有单一的继承链。换句话说,给定的类最多只有一个直接的父类。这不是必需的,在多重继承中,一个类可以有多个基类。图 5-11 给出了一种多重继承设计。在此仍然有一个基类 Animal,根据大小细分这个类。此外根据食物划分了一个独立的层次类别,根据移动方式又划分了一个层次类别; 所有类型的动物都是这三个类的子类。

在这里插入图片描述

在这里插入图片描述

考虑用户界面环境,假定用户可单击某张图片。这个对象好像既是按钮又是图片, 因此其实现同时继承了 Image 类和 Button 类,如图 5-12某些情况下多重继承可能很有用,但必须记住它也有很多缺点。许多程序员不喜欢多重继承,C++明确支持这种关系,而 Java 语言根本不予支持,除非通过多个接口来继承(抽象基类)。批评多重继承是有原因的。

首先,用图形表示多重继承十分复杂。如图 5S-11 所示,当存在多重继承和交叉线时,即使简单的类图也会变得非常复杂。类层次结构旨在让程序员更方便地理解代码之间的关系。而在多重继承中,类可有多个彼此没有关系的父类。将这么多类加入对象的代码中,能跟踪发生了什么吗?其次, 多重继承会破坏清晰的层次结构。在动物示例中, 使用多重继承方法意味着 Animal 基类的作用降低,因为描述动物的代码现在分成三个独立的层次。尽管图 5-11 中的设计显示了三个清晰的层次,但不难想象它们会变得如何凌乱。例如,如果发现所有的 Jumper 不仅以同样的方式移动,还吃同样的食物,该怎么办? 由于层次是独立的,因此无法在不添加其他基类的情况下加入移动和食物的概念。

最后,多重继承的实现很复杂。如果两个基类以不同方式实现了相同的行为,该怎么办? 两个基类本身是同一个基类的子类,可以这样吗? 这种可能让实现变得复杂,因为在代码中建立这样复杂的关系会给笔者和读者带来挑战。其他语言取消多重继承的原因是,通常可以避免使用多重继承。在控制某个项目的设计时,重新考虑层次结构,通常可避免引入多重继承。

混入类

混入(mix-im)类代表类之间的另一种关系。在 C++中,混入类的语法类似于多重继承,但语义完全不同。混入类回答“这个类还可以做什么”这个问题,答案经常以“able”结尾。使用混入类,可向类中添加功能,而不需要保证是完全的“是一个”关系。可将它当作一种分享(share-with)关系。回到动物园示例,假定想引入某些动物可以“做宠物”这一概念。也就是说,有些动物可能不需要训练就可以作为动物园游客的宠物。所有可以做宠物的动物支持“做宠物”行为。由于可以做宠物的动物没有其他的共性,且不想破坏已经设计好的层次结构,因此 Pettable 就是很好的混入类。

混入类经常在用户界面中使用。可以说 Image 能够单击(Clickable),而不需要说 PictureButton 类既是 Image又是 Button。桌面上的文件夹图标可以是一张可拖动(Draggable)、可单击(Clickable)的图片(mage)。软件开发人员总是喜欢弄一大堆有趣的形容词。当考虑类而不是代码的差异时,混入类和基类的区别还有很多。因为范围有限,混入类通常比多重层次结构容易理解。Pettable 混入类只是在已有类中添加了一个行为,Clickable 混入类或许仅添加了“按下鼠标”和“释放鼠标”行为。此外,混入类很少会有庞大的层次结构,因此不会出现功能的交叉混乱。第 28 章将详细介绍混入类。

抽象

接口与实现

抽象的关键在于有效分离接口与实现。实现是用来完成任务的代码,接口是其他用户使用代码的方式。在C 中,描述库函数的头文件是接口,在面向对象编程中,类的接口是公有属性和方法的集合。优秀的接口只包含公有行为,类的属性/变量绝不应该公有,但是可以通过公有方法公开,这些方法称为获取器和设置器。

决定公开的接口

当设计类时,其他程序员如何与你的对象交互是一个问题。在 C++中,类的属性和方法可以是公有的(public)、受保护的(protected)和私有的(privatej。将属性或行为设置为 public 意味着其他代码可以访问它们。protected 意味着其他代码不能访问这个属性或行为,但子类可以访问。private 是最严格的控制,意味着不仅其他代码不能访问这个属性或行为,子类也不能访问。注意,访问修饰符在类级别而非对象级别发挥作用。例如,这意味着类的方法可访问同一个类的其他对象的私有属性或私有方法。

设计公开的接口就是选择哪些应该成为 public。当与其他程序员一起完成大项目时,应该将设计公开接口作为其中一个步骤。

\1. 考虑用户

设计公开接口的第一步是考虑为谁设计。用户是团队中的其他成员吗? 这个接口只是个人使用吗? 公司外面的程序员会使用接口吗? 是某个用户还是国外的承包商? 除了判断谁会用到接口以外, 还应该注意设计目标。

如果接口供自己使用,那么设计起来会更灵活,为满足自己的需要可加以改变。然而,应该记住团队中的角色会改变,很有可能在某一天,其他人也会用这个接口。

设计供公司内其他程序员使用的接口有一点不同之处。某种意义上,接口变成你与他们之间的契约。例如,如果你实现程序的数据存储组件,其他人依靠这个接口支持某些操作。你应该找出团队中其他成员需要你的类完成的所有工作。 他们需要版本控制吗? 可以存储什么类型的数据? 作为契约, 接口应该看成几乎不可改变的。如果在开始编码之前就接口达成一致,而你在开始编码之后改变了接口,就会听到许多抱怨。

如果用户是外部客户,对设计有不同的要求,那么理想情况下,目标客户会参与指定接口公开的功能。你应该同时考虑用户需要的特定功能和他们将来可能需要的功能。接口中使用的术语必须是客户熟悉的,并且必须为这些客户编写文档。设计中不应该出现内部的笑话、代号和程序员的但语。

考虑目的

编写接口有很多理由。在编写代码前,甚至在决定要公开的功能之前.必须理解接口的目的。应用程序编程接口(API)API 是一种外部可见机制,用于在其他环境中扩展产品或者使用其功能。如果说内部的接口是契约,那么API 更接近于雕刻在石头上的法律。一旦用户开始使用你的APL, 哪怕他们不是公司的员工,他们也不希望 API发生改变,除非加入帮助他们的新功能。在交给用户使用之前,应该关心 API 的设计,并与用户进行商谈。设计 API 时主要考虑易用性和灵活性。由于接口的目标用户并不熟悉产品内部的运行方式,因此学习使用API 是一个循序渐进的过程。毕竟,公司向用户公开这些 API 的目的是想让用户使用 API。如果使用难度太大,API就是失败的。灵活性常与此对立,产品可能有许多不同的用途,我们希望用户能使用产品提供的所有功能。然而,如果一个API 让用户做产品可做的任何事,那么它肯定会过于复杂。

正如编程格言所说,“好的 API 使容易的情况变得更容易,使艰难的情况变得容易”"。也就是说,API应该很容易使用。大多数程序员想要做的事情就是访问。然而,API 应该允许更高级的用法,因此在罕见的复杂情况和常见的简单情况之间做出折中是可以接受的。

构建理想的重用代码

  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值