我们已经讨论过 RAII 这个成语了。C++ 中的一些语言用法和编程习惯用法乍一看可能显得陌生或毫无意义,但它们确实是有目的的。在这一章中,我们将探索其中一些奇怪的用法和习语,以了解它们来自哪里以及为什么使用它们。
您通常会看到 C++ 使用语法++ i
而不是i++
来增加一个整数。这样做的原因一部分是历史性的,一部分是有用的,还有一部分是某种秘密握手。你将会看到的一个常见的地方是在一个for
循环中(例如,for (int i = 0; i < someNumber; ++ i) { ... })
)。为什么 C++ 程序员用++ i
而不用i++
?让我们考虑一下这两个运算符的含义。
int i = 0;
int x = ++ i;
int y = i++ ;
在前面的代码中,当这三条语句都执行完毕时,i
将等于 2。但是x
和y
会有什么相同之处呢?它们都等于 1。这是因为语句中的预增量运算符++ i
表示“增量i
,并给出新值i
作为结果。”所以在赋值x
时,i
从 0 变为 1,i
的新值 1 被赋值给x
。语句i++
中的后递增运算符表示“递增i
,并给出i
的初始值作为结果。”所以在赋值y
时,i
从 1 变为 2,i
1 的原始值被赋值给y
。
如果我们按照编写的那样一步一步地分解指令序列,消除前增量和后增量操作符,并用常规加法替换它们,我们会意识到,要执行对y
的赋值,我们需要一个额外的变量来保存i
的原始值。结果会是这样的:
int i = 0;
// int x = ++ i;
i = i + 1;
int x = i;
// int y = i++ ;
int magicTemp = i;
i = i + 1;
int y = magicTemp;
事实上,早期的编译器曾经做过类似的事情。现代编译器现在确定,首先分配给y
没有可观察到的副作用,因此它们生成的汇编代码,即使没有优化,通常看起来也像这个 C++ 代码的汇编语言等价物:
int i = 0;
// int x = ++ i;
i = i + 1;
int x = i;
// int y = i++ ;
int y = i;
i = i + 1;
在某些方面,++ i
语法(尤其是在for
循环中)是 C++ 早期的延续,甚至是之前的 C。知道其他 C++ 程序员使用它,自己使用它让其他人知道你至少对 C++ 的用法和风格有些熟悉——秘密握手。有用的部分是你可以写一行代码,int x = ++ i;
,得到你想要的结果,而不是写两行代码:i++ ;
后面跟着int x = i;
。
| | 提示:虽然您可以通过捕获预增量运算符的结果等技巧来到处保存一行代码,但通常最好避免将一堆操作组合在一行中。编译器不会生成更好的代码,因为它只会将那一行分解成它的组成部分(就像你写了多行一样)。因此,编译器将生成以高效方式执行每个操作的机器代码,遵守操作顺序和其他语言约束。你所要做的就是迷惑其他需要查看你的代码的人。您还将为 bug 引入一个完美的情况,要么是因为您误用了某些东西,要么是因为有人在不理解代码的情况下进行了更改。如果六个月后再来看,你自己也不理解代码的可能性也会增加。 |
C++ 在其诞生之初,采用了许多来自 C 的东西,包括使用二进制零作为空值的表示。这些年来,这造成了无数的 bug。我并没有因此而责怪克尼根、里奇、斯特鲁特普或其他任何人;考虑到 70 年代和 80 年代早期可用的计算机,他们在创建这些语言时取得了惊人的成就。试图弄清楚创建计算机语言时会出现什么问题是一项极其困难的任务。
尽管如此,在早期,程序员意识到在他们的代码中使用文字 0 在某些情况下会产生混淆。例如,假设您写道:
int* p_x = p_d;
// More code here...
p_x = 0;
您的意思是将指针设置为空(即p_x = 0;
)还是将指向的值设置为 0(即*p_x = 0;
)?即使代码相当复杂,调试器也可能花费大量时间来诊断此类错误。
这种实现的结果是采用了NULL
预处理器宏:#define NULL 0
。这将有助于减少错误,如果您看到*p_x = NULL;
或p_x = 0;
,那么,假设您和其他程序员一致地使用空宏,错误将更容易发现、修复,并且修复将更容易验证。
但是由于NULL
宏是一个预处理器定义,由于文本替换,编译器永远看不到 0 以外的任何东西;它无法警告您可能的错误代码。如果有人将NULL
宏重新定义为另一个值,可能会导致各种额外的问题。重新定义NULL
是一件非常不好的事情,但是有时候程序员也会做坏事。
C++ 11 增加了一个新的关键字nullptr
,当你需要给一个指针赋一个空值或者检查一个指针是否为空时,可以也应该用它来代替0
、NULL
以及其他任何东西。使用它有几个很好的理由。
nullptr
关键字是语言关键字;它不会被预处理器消除。由于它传递给编译器,编译器可以检测错误并生成使用文字0
或任何宏都无法检测或生成的使用警告。
它也不能被意外或有意地重新定义,不像NULL
这样的宏。这消除了宏可能引入的所有错误。
最后,它提供了未来的验证。将二进制零作为空值是一个实际的决定,但它仍然是任意的。另一个合理的选择可能是将 null 作为无符号本机整数的最大值。这样的价值有积极的一面,也有消极的一面,但据我所知,没有任何东西会让它无法使用。
有了nullptr
,在不改变任何完全采用nullptr
的 C++ 代码的情况下,改变特定操作环境的 null 值突然变得可行。编译器可以与nullptr
进行比较,或者将nullptr
赋值给一个指针变量,并根据目标环境的要求生成任何机器代码。试图用二进制 0 做同样的事情是非常困难的,如果不是不可能的话。如果将来有人决定设计一个计算机体系结构和操作系统,为所有内存地址添加一个空标志位来指定空,现代 C++ 可以支持这一点,因为nullptr
。
你通常会看到人们写代码,比如if (nullptr == p_a) { ... }
。我没有在样品中遵循这种风格,因为它在我看来是错误的。在我用 C 和 C++ 编写程序的 18 年里,我从未遇到过这种风格所避免的问题。尽管如此,其他人也有这样的问题。此样式可能是要求您遵循的样式规则的一部分;因此,值得探讨。
如果你写的是if (p_a = nullptr) { ... }
而不是if (p_a == nullptr) { ... }
,那么你的程序会把空值赋给p_a
,而if
语句的计算结果总是假的。C++,由于它的 C 传统,允许你在控制语句的括号内有一个计算为任何整数类型的表达式,比如if
。C# 要求任何这样的表达式的结果都是布尔值。因为你不能给类似nullptr
的东西赋值或者给常量赋值,比如 3 和 0.0F,如果你把 R 值放在等式检查的左边,编译器会提醒你这个错误。这是因为您将为不能赋值的东西赋值。
出于这个原因,一些开发人员已经开始以这种方式编写他们的平等检查。重要的部分不是你选择哪种风格,而是你知道在 C++ 中像if
表达式这样的东西里面的赋值是有效的。这样,你就知道要注意这样的问题。
无论做什么,都不要故意写if (x = 3) { ... }
这样的语句。这是非常糟糕的风格,这使得你的代码更难理解,更容易出现开发错误。
| | 注意:从 Visual Studio 2012 RC 开始,Visual c++ 编译器接受但不实现异常规范。然而,如果包含一个
throw()
异常规范,编译器很可能会优化掉它在抛出异常时为支持展开而生成的任何代码。如果标记为throw()
的函数引发异常,您的程序可能无法正常运行。其他实现抛出规范的编译器会期望它们被正确标记,所以如果您的代码需要用另一个编译器编译,您应该实现正确的异常规范。 |
| | 注意:从 C++ 11 开始,不推荐使用
throw()
语法的异常规范(称为动态异常规范)。因此,它们将来可能会从语言中删除。noexcept
规范和运算符是该语言功能的替代,但从 Visual Studio 2012 RC 开始,没有在 Visual C++ 中实现。 |
C++ 函数可以通过throw()
异常规范关键字指定是否抛出异常,如果是,抛出什么类型的异常。
例如,int AddTwoNumbers(int, int) throw();
声明了一个函数,由于空括号,声明它不抛出任何异常,不包括那些它在内部捕获的并且不重新抛出的异常。相比之下,int AddTwoNumbers(int, int) throw(std::logic_error);
声明了一个函数,声明它可以抛出一个类型为std::logic_error
的异常,或者从该异常派生的任何类型。
函数声明int AddTwoNumber(int, int) throw(...);
声明可以抛出任何类型的异常。这种语法是微软特有的,所以对于可能需要用 Visual C++ 编译器以外的其他工具编译的代码,您应该避免使用它。
如果没有出现说明符,比如在int AddTwoNumbers(int, int);
中,那么函数可以抛出任何异常类型。这相当于拥有throw(...)
说明符。
C++ 11 增加了新的noexcept(bool expression)
规范和运算符。从 Visual Studio 2012 RC 开始,Visual C++ 不支持这些,但我们将简要讨论它们,因为它们无疑将在未来被添加。
说明符noexcept(false)
相当于throw(...)
和没有throw
说明符的函数。例如int AddTwoNumbers(int, int) noexcept(false);
相当于int
AddTwoNumber(int, int) throw(...);
和int AddTwoNumbers(int, int);
。
说明符noexcept(true)
和noexcept
相当于throw()
。换句话说,它们都指定该函数不允许任何异常从其中逸出。
当重写虚拟成员函数时,派生类中重写函数的异常规范不能指定超出为其重写的类型声明的异常。我们来看一个例子。
#include <stdexcept>
#include <exception>
class A
{
public:
A(void) throw(...);
virtual ~A(void) throw();
virtual int Add(int, int) throw(std::overflow_error);
virtual float Add(float, float) throw();
virtual double Add(double, double) throw(int);
};
class B : public A
{
public:
B(void); // Fine, since not having a throw is the same as throw(...).
virtual ~B(void) throw(); // Fine since it matches ~A.
// The int Add override is fine since you can always throw less in
// an override than the base says it can throw.
virtual int Add(int, int) throw() override;
// The float Add override here is invalid because the A version says
// it will not throw, but this override says it can throw an
// std::exception.
virtual float Add(float, float) throw(std::exception) override;
// The double Add override here is invalid because the A version says
// it can throw an int, but this override says it can throw a double,
// which the A version does not specify.
virtual double Add(double, double) throw(double) override;
};
因为throw
异常规范语法被弃用,所以应该只使用它的空括号形式,throw()
,以便指定特定函数不抛出异常;否则,就别说了。如果你想让别人知道你的函数会抛出什么异常,考虑在你的头文件或者其他文档中使用注释,确保它们是最新的。
noexcept(bool expression)
也是一个运算符。当用作运算符时,如果不能抛出异常,它会取一个求值为 true 的表达式,如果能抛出异常,则取一个求值为 false 的表达式。请注意,结果是简单的评估;它检查所有调用的函数是否都是noexcept(true)
,以及表达式中是否有 throw 语句。如果它发现任何抛出语句,甚至是那些你知道是不可达的语句(例如,if (x % 2 < 0) { throw "This computer is broken"; }
),它仍然可以评估为假,因为编译器不需要进行深层分析。
指向实现的指针习惯用法是一种较老的技术,在 C++ 中受到了很多关注。这很好,因为它相当有用。该技术的本质是在头文件中定义类的公共接口。您拥有的唯一数据成员是指向正向声明的类或结构的私有指针(包装在std::unique_ptr
中,用于异常安全的内存处理),它将作为实际的实现。
在源代码文件中,您定义了这个实现类及其所有成员函数和成员数据。接口中的公共函数为其功能调用实现类。结果是,一旦你确定了类的公共接口,头文件就不会改变。因此,由于不影响公共接口的实现更改,包含头的源代码文件将不需要重新编译。
每当您想要对实现进行更改时,唯一需要重新编译的是实现类所在的源代码文件,而不是包含类头文件的每个源代码文件。
这里有一个简单的例子。
示例:皮条客示例\三明治. h
#pragma once
#include <memory>
class SandwichImpl;
class Sandwich
{
public:
Sandwich(void);
~Sandwich(void);
void AddIngredient(const wchar_t* ingredient);
void RemoveIngredient(const wchar_t* ingredient);
void SetBreadType(const wchar_t* breadType);
const wchar_t* GetSandwich(void);
private:
std::unique_ptr<SandwichImpl> m_pImpl;
};
示例:皮条客示例\三明治. cpp
#include "Sandwich.h"
#include <vector>
#include <string>
#include <algorithm>
using namespace std;
// We can make any changes we want to the implementation class without
// triggering a recompile of other source files that include Sandwich.h since
// SandwichImpl is only defined in this source file. Thus, only this source
// file needs to be recompiled if we make changes to SandwichImpl.
class SandwichImpl
{
public:
SandwichImpl();
~SandwichImpl();
void AddIngredient(const wchar_t* ingredient);
void RemoveIngredient(const wchar_t* ingredient);
void SetBreadType(const wchar_t* breadType);
const wchar_t* GetSandwich(void);
private:
vector<wstring> m_ingredients;
wstring m_breadType;
wstring m_description;
};
SandwichImpl::SandwichImpl()
{
}
SandwichImpl::~SandwichImpl()
{
}
void SandwichImpl::AddIngredient(const wchar_t* ingredient)
{
m_ingredients.emplace_back(ingredient);
}
void SandwichImpl::RemoveIngredient(const wchar_t* ingredient)
{
auto it = find_if(m_ingredients.begin(), m_ingredients.end(), [=] (wstring item) -> bool
{
return (item.compare(ingredient) == 0);
});
if (it != m_ingredients.end())
{
m_ingredients.erase(it);
}
}
void SandwichImpl::SetBreadType(const wchar_t* breadType)
{
m_breadType = breadType;
}
const wchar_t* SandwichImpl::GetSandwich(void)
{
m_description.clear();
m_description.append(L"A ");
for (auto ingredient : m_ingredients)
{
m_description.append(ingredient);
m_description.append(L", ");
}
m_description.erase(m_description.end() - 2, m_description.end());
m_description.append(L" on ");
m_description.append(m_breadType);
m_description.append(L".");
return m_description.c_str();
}
Sandwich::Sandwich(void)
: m_pImpl(new SandwichImpl())
{
}
Sandwich::~Sandwich(void)
{
}
void Sandwich::AddIngredient(const wchar_t* ingredient)
{
m_pImpl->AddIngredient(ingredient);
}
void Sandwich::RemoveIngredient(const wchar_t* ingredient)
{
m_pImpl->RemoveIngredient(ingredient);
}
void Sandwich::SetBreadType(const wchar_t* breadType)
{
m_pImpl->SetBreadType(breadType);
}
const wchar_t* Sandwich::GetSandwich(void)
{
return m_pImpl->GetSandwich();
}
示例:皮条客示例\皮条客示例. cpp
#include <iostream>
#include <ostream>
#include "Sandwich.h"
#include "../pchar.h"
using namespace std;
int _pmain(int /*argc*/, _pchar* /*argv*/[])
{
Sandwich s;
s.AddIngredient(L"Turkey");
s.AddIngredient(L"Cheddar");
s.AddIngredient(L"Lettuce");
s.AddIngredient(L"Tomato");
s.AddIngredient(L"Mayo");
s.RemoveIngredient(L"Cheddar");
s.SetBreadType(L"a Roll");
wcout << s.GetSandwich() << endl;
return 0;
}