错误/异常处理,一直是程序员痛恨,却无法摆脱的梦魇。如果一个系统中仅包含happy path
的实现,那么这个系统的代码规模会显著缩小,而逻辑清晰度则大大增加。
C++
以及更加现代的语言,都提供了概念:异常。异常机制大大的简化了程序员的工作,并且让实现代码能够更加关注真正的业务逻辑。
但异常机制并不是免费的,尤其是C++
的异常机制,对于空间和时间都有较大的消耗。所以,大多数实时性嵌入式系统都会禁止使用异常。于是,嵌入式程序员只好重新回到“一步一岗,严密防卫”的编程模式下。
即便允许使用异常,只要一个类可能出现异常的状况,那么这个类的所有客户都必须编写相应的异常处理代码,这仍然是一个让人不悦的工作。
错误/异常就像强盗,一旦让其闯入,你就需要不断与其周旋:编写防御代码,打印错误日志,DEBUG,等等……耗去你的时间和精力,还把你原本漂亮的代码搞的一团糟。
所以,如果能够在事先就杜绝一些错误发生的可能,那么程序员就无须为之付出相应的精力,在保持系统简洁的同时,也会让系统更加健壮 (你能确保程序员的错误处理代码没有错吗?)。
C++
作为一门强类型的静态语言,我们要充分利用它的优势。让它的严格类型检查作为我们忠实的看门狗,在编译时就可以辨认出任何可能溜进来的bug。从而不让用户存在犯错的可能(恶意用户刻意的hack
除外)。
错误处理
基于防御式编程的思想,程序员需要在各处对可能的错误进行判断和处理,从而为系统实现引入额外的复杂度,而这部分代码有时候在系统中的占比是惊人的。很多系统都有测试覆盖率的要求,但当团队进行系统测试时,却发现测试覆盖率一旦达到某个比例时(我最近听到的一个案例,此比例是30%
),再想提高这个比例就变得非常困难。通过工具一查看,发现大部分无法覆盖的是错误处理相关代码。而其中,有些代码永远也无法得到运行,即我们常说的死代码。 让我们看一个简单的例子:
struct Object
{
Status f1(Foo* foo)
{
if(foo == NULL)
{
// 或许能得到运行的代码(但也只是或许而已)
// 错误处理:blah...blah...
return FAILED;
}
if(f2(foo) != SUCCESS)
{
// 死代码
// 错误处理:blah...blah...
}
// 其它代码
// ...
return SUCCESS;
}
private:
Status f2(Foo* foo)
{
if(foo == 0)
{
// 死代码
// blah...blah...
return FAILED;
}
value = foo->getValue() + 5;
return SUCCESS;
}
private:
int value;
// ...
};
出现这种问题的原因,一则是防卫过度:即私有函数是否需要对传入的空指针进行防卫?事实上,将那些死代码删除,在任何情况下,都不会让系统更不安全。 而清理后的结果,却会比原来要清晰和简洁许多。
但那些遵从怀疑一切哲学的谨慎编程者,对这种做法持反对态度。他们强烈认同必须通过考验才可获得信任的价值观。他们认为:私有函数也是函数。而作为函数,它不应该知道究竟谁是它的客户,因此也不应该对客户给予信任。更何况,代码是随时可以改变的,当前的用户能够保证其安全性,并不意味这随后的新增客户也会同样遵守。
我们先不去讨论这样的观点是否正确。重要的是,无论你属于那一派,你都肯定会同意:如果一件事不可能出错,你就不会再为之焦虑。
指针 vs. 引用
在C
时代,指针是间接访问对象的唯一方式。由于任何一个指针都可是是空指针,谨慎的程序员会在获取到一个指针时,都会先判断其是否为空。这种代码在任何一个正式的C
项目中比比皆是。
到了C++
时代,为了避免这类问题,一种新的对象访问方式诞生了:引用。不幸的是,由于很多C++
程序员都是从C
工程师转化而来的,所以,很多项目仍然在固执的大量使用指针。比如:
Status f(Object* object)
{
if(object == 0) {
// 错误处理代码:记日志,发告警等等。
return FAILED;
}
// 对 object 所指对象进行操作
object->f();
// ...
}
和指针不同,引用是对象的别名。因此,引用具有如下性质:
- 引用在定义时即需要赋值;
- 一旦赋值,你不可能让其引用其它对象;
- 对引用进行取地址操作,就是对对象进行取地址操作。
所以,一旦将参数改为引用方式,你就得到了传入对象非空的承诺,代码会一下子简洁许多:
Status f(Object& object)
{
object.f();
// ...
}
空引用
引用的非空特性给我们带来很大的便利。不幸的是,现实在理想面前总是很骨感:空引用的的确确存在。比如:
void f(Object& object);
Object* object = 0;
// 空引用!
f(*object);
这个残酷的现实,动摇了我们的信念:使用指针还是引用,似乎并没有本质差别,而引用还有那么一堆约束和限制,还是使用指针更好。
事前条件
但是,从契约式编程(Design By Contract
)的角度看,一旦一个函数的某个参数声明为引用类型,那就意味着它明确的定义了一个事前条件(Precondition
):函数的调用者有义务确保传入的引用非空。否则,空引用引起的崩溃应该由调用者负责。
如果你仍然对此持有异议,那么不妨设想一下:让我们重新回到指针时代,看看会发生什么。
指针事实上有三种状态:
- 空:空指针
- 有效:指向一个合法对象
- 非法:指向一个未知世界
而引用却只有两种状态:
- 有效:引用了一个合法对象
- 非法:引用了一个非法对象(包括空对象)
如果一个函数的某个参数为指针类型,那么作为函数的实现者,你也只能检测指针是否为空,却永远无法保证客户是否会传入一个非法指针。所以,保证指针的合法性(无论是否为空),当然是调用者的责任。
由此不难得出结论:保证一个引用不会是非法引用也应该是调用者的责任。
事后条件
而当一个函数的返回值是一个引用时,这样的声明则明确定义了一个事后条件(Post Condition
)。而保证引用的合法性,就成了函数实现者的义务。
// 函数实现者必须保证返回值的合法性
Object& f();
所以,实现者必须保证返回的对象不能是个临时对象。比如:
Object& getObject()
{
// 不要这样做
Object object;
// ...
return object;
}
所以,也要避免可能造成内存泄漏。比如:
Object& getObject()
{
return *(new Object);
}
而返回引用的优势则在于:它让所有的客户代码无须再进行相关的错误处理。
// 无须判断空指针
getObject().f();
何时必须使用指针
由于引用比指针更加严格,所以,并非在所有场景下引用都可以代替指针。下面列出了几种常见理由(但并非全部)。
空指针是一种合法状态
指针与引用貌似很像,但事实上存在在本质的语义差别:
- 指针是
Maybe
或Optional
语义; - 引用是
Value
语义。
因而,一些时候,空指针也是一种合法状态(正如Maybe
语义的Nothing
一样),比如,单向链表中最后一个节点的next
指针;一颗树Root
节点的 parent
指针。比如:
struct Node
{
// ...
private:
// 作为 Root 节点,parent 可能为空 Node* parent;
// 作为 Leaf 节点,children 可能为空 Node* leftChild;
Node* rightChild;
};
这种情况下,我们只能使用指针,而不能使用引用。
需变更所指对象
如果一个指针在其生命周期内需要改变所指向的对象。比如:
struct Foo
{
Foo(Object* object) : object(object) {}
// 变更接口
void setObject(Object* object)
{
this->object = object;
}
// ...
private:
// 只能使用指针
Object* object;
};
作为输出参数,需要得到一个对象
函数希望通过某个输出参数以取得一个对象,比如:
Status getObject(int key, Object** object)
{
// ...
(*object) = findObjectByKey(key);
if(*object == 0)
{
return NOTFOUND;
}
// ...
return SUCCESS;
}
当然,当通过返回值来返回对象时,如果存在查找不到的可能性,也应该使用指针:
Object* getObject(int key)
{
Object* object = findObjectByKey(key);
if(object == 0)
{
return 0;
}
// ...
return object;
}
除非你通过空对象模式,避免空指针的可能性:
Object& getObject(int key)
{
Object* object = findObjectByKey(key);
if(object == 0)
{
return NullObject::getInstance();
}
// ...
return *object;
}
动态分配内存 vs. 静态分配
单例模式(Singleton
)估计是OO
程序员使用最为广泛的设计模式。一般而言,从标准的教科书上,Singleton
的实现模式如下:
struct Foo
{
static Foo* getInstance();
// public functions
private:
Foo();
private:
static Foo* foo;
};
Foo* Foo:foo = 0;
Foo* Foo::getInstance()
{
if(foo != 0) return foo;
foo = new Foo();
return foo;
}
这样的实现方式至少有两个问题:
- 内存分配失败的可能;
- 由于内存分配可能失败,所有的客户代码为了安全,都必须检查
getInstance()
是否返回空指针。
这无疑会增加客户代码的复杂度。为了避免这类问题,我们必须从源头消灭这样的可能性:将内存分配从动态改为静态分配:
struct Foo
{
static Foo& getInstance();
// public functions
private:
Foo();
};
Foo& Foo::getInstance()
{
static Foo foo;
return foo;
}
这样的实现方式可以带来诸多好处:
- 由于内存是静态分配的,一旦系统成功加载,就永远也不可能失败,因而可以将返回值类型改为引用;
- 这种局部静态分配的方式,让实例的构造是
lazy
的,这可以有效避免C++
静态成员初始化顺序不确定带来的问题。 - 对于
getInstance
函数的实现,由于不需要每次都判断指针是否为空,甚至会带来性能上的一些好处。(哪怕对于性能影响微乎其微,但也要遵守不做不必要的劣化原则); - 对于客户,杜绝了指针为空的可能性,可以编写更加简洁的代码,避免思考空指针处理的逻辑,甚至也会对性能有些好处(逻辑同上)。
枚举 vs. 投币模式
假设现在有个需求,要求你实现一个长度类。类的使用者,可以使用英里 (Mile
),码(Yard
)和英尺(Feet
)为单位来表现一个长度。并且可以比较任意两个长度是否相等。
typedef unsigned int Amount;
enum Unit {
FEET = 1,
YARD = 3 * FEET,
MILE = 1760 * YARD
};
struct Length
{
Length(const Amount& amount, const Unit unit) : amountInBaseUnit(unit * amount)
{}
bool operator==(const Length& another) const
{
return amountOfBaseUnit == another.amountOfBaseUnit;
}
private:
Amount amountOfBaseUnit;
};
这个实现非常简洁。它使用枚举来纪录长度单位之间的转换系数,无论使用者以何单位来纪录一个长度,Length
总是会在构造时将其转化为以基准单位为单位的数量,而基准单位在这里很明显是FEET。
但这个实现的一个重大问题是:由于在C++ 98
里,枚举并非强类型。事实上使用者可以传入任何整数作为 unit
参数。
基于防御式编程的哲学,我们需要处理这种情况。所以,一种做法是在构造函数里进行检查,如果传入一个非法的unit
,就抛出异常。
struct Length
{
Length(const Amount& amount, const Unit unit) : amountInBaseUnit(unit * amount)
{
if(unit != MILE && unit != YARD && unit != FEET)
{
throw InvalidUnitException;
}
}
// ...
private:
Amount amountOfBaseUnit;
};
这个实现的主要问题是:构造函数可能会抛出异常,因而客户代码必须处理此异常。更何况,在嵌入式系统上,异常往往是不允许使用的。因而必须额外提供一个额外的init
函数以允许返回失败。
当然,你还可以进行其它方式的实现,来应对上述问题。但无论你怎样做,只要Unit
是个枚举,你总是逃不脱要对错误进行检测和处理。然后把你原有的简洁设计搞的一团糟。
避免处理错误的最好方式是不要让错误有发生的可能性。所以我们将实现方式改为:
struct Unit
{
// 允许用户使用的 Unit 对象
static Unit getMile() { return MILE_FACTOR; }
static Unit getYard() { return YARD_FACTOR; }
static Unit getFeet() { return FEET_FACTOR; }
Amount toAmountInBaseUnit(const Amount& amount) const
{
return conversionFactor * amount;
}
private:
// 构造函数私有以避免用户实例化 Unit 对象
Unit(unsigned int conversionFactor)
: conversionFactor(conversionFactor)
{}
private:
enum
{
FEET_FACTOR = 1,
YARD_FACTOR = 3 * FEET_FACTOR,
MILE_FACTOR = 1760 * YARD_FACTOR
};
private:
unsigned int conversionFactor;
};
// 通过宏定义,提供和原来一样的 Unit 使用方式。
#define MILE Unit::getMile()
#define YARD Unit::getYard()
#define FEET Unit::getFeet()
struct Length
{
Length(const Amount& amount, const Unit& unit)
: amountInBaseUnit(unit.toAmountInBaseUnit(amount))
{}
// ...
private:
Amount amountOfBaseUnit;
};
这种处理问题的手段,被称为投币模式(Slug Pattern
)。
类复用 vs. 模板复用
现在,我们在上面的例子基础上增加一个新需求:实现一个容量类。此类的使用者,可以使用盎司(OZ
)和汤匙(TBSP
)为单位来表现一个容量。并且可以比较任意两个容量是否相等。
不难看出,容量和长度的需求非常相似。所以,它们的代码是可以复用的。于是,我们将这两个体系合二为一,起一个更通用的名字:Quantity
。
struct Quantity
{
Quantity(const Amount& amount, const Unit& unit)
: amountInBaseUnit(unit.toAmountInBaseUnit(amount))
{}
bool operator==(const Quantity& another) const
{
return amountOfBaseUnit == another.amountOfBaseUnit;
}
private:
Amount amountOfBaseUnit;
};
struct Unit
{
// 允许用户使用的长度 Unit 对象
static Unit getMile() { return MILE_FACTOR; }
static Unit getYard() { return YARD_FACTOR; }
static Unit getFeet() { return FEET_FACTOR; }
// 允许用户使用的容量 Unit 对象
static Unit getTbsp() { return TBSP_FACTOR; }
static Unit getOz() { return OZ_FACTOR; }
// ...
private:
enum
{
FEET_FACTOR = 1,
YARD_FACTOR = 3
MILE_FACTOR = 1760 * YARD_FACTOR
};
enum
{
TBSP_FACTOR = 1,
OZ_FACTOR = 2 * TBSP_FACTOR,
};
private:
unsigned int conversionFactor;
};
#define MILE Unit::getMile()
#define YARD Unit::getYard()
#define FEET Unit::getFeet()
#define OZ Unit::getOz()
#define TBSP Unit::getTbsp()
我们现在通过一套类就解决了两个体系的问题,我们不仅为自己对DRY原则的坚持和强大的抽象能力感到骄傲。
但没过多久,你就会收到一个来自于用户的Bug
报告: 1 FEET
怎能等于 1 TBST
呢?
哦,长度和容量是两种不同的体系。我们原来忘了在判断相等时对两种体系进行区分。
作为富有经验的程序员,这个难不倒我们:只需要为Unit
添加一个类型,对两种不同的Unit
进行区分即可。
struct Unit
{
static Unit getMile() { return Unit(MILE_FACTOR, LENGTH_TYPE); }
// ...
static Unit getOz() { return Unit(OZ_FACTOR, VOLUME_TYPE); }
// 增加一个判断 UnitType 是否一致的借口
bool hasSameType(const Unit& another) const
{
return unitType == another.unitType;
}
// ...
private:
Unit(unsigned int conversionFactor, UnitType unitType)
: conversionFactor(conversionFactor)
, unitType(unitType)
{}
// 增加枚举类型:UnitType
enum UnitType
{
LENGTH_TYPE,
VOLUME_TYPE
};
// ...
private:
unsigned int conversionFactor; UnitType unitType;
};
然后,我们将Quantity
的实现改为下面的样子:
struct Quantity
{
Quantity(const Amount& amount, const Unit& unit)
: amount(amount)
, unit(unit)
{}
bool operator==(const Quantity& another) const
{
// 先判断两个 Unit 是否属于同一类型
return unit.hasSameType(another.unit) &&
getAmountInBaseUnit() == another.getAmountInBaseUnit();
}
private:
Amount getAmountInBaseUnit() const
{
return unit.toAmountInBaseUnit(amount);
}
private:
Amount amount; Unit unit;
};
这样做总算满足了要求,尽管其背后的语义仍然略显怪异(为什么允许一个长度和一个容量对比相等性?)。但如果我们现在增加一个新的需求:加法运算,其实现方式将会变得非常棘手。
Quantity Quantity::operator+(const Quantity& another)
{
if(not unit.hasSameType(another)) {
// 该如何做? 抛出异常? 还是返回一个非法的 Quantity?
}
// ...
}
而避免这种问题的方法是:禁止两个不同体系的Quantity
之间进行任何互操作。而模板可以帮助我们达到目标:
template <typename UNIT>
struct Quantity
{
Quantity(const Amount& amount, const UNIT& unit)
: amountInBaseUnit(UNIT.toAmountInBaseUnit(amount))
{}
bool operator==(const Quantity& another) const
{
return getAmountInBaseUnit() == another.getAmountInBaseUnit();
}
Quantity operator+(const Quantity& another)
{
return amountInBaseUnit + another.amountInBaseUnit;
}
private:
Quantity(const Amount& amountInBaseUnit)
: amountInBaseUnit(amountInBaseUnit)
{}
private:
Amount amountInBaseUnit;
};
由于两个体系的Unit
的算法和数据都完全一样,所以这里我们为它们提取一个基类。其中,我们将Unit
的构造函数设为protected
的,以避免用户直接创建Unit
实例。
struct Unit
{
Amount toAmountInBaseUnit(const Amount& amount) const;
protected:
Unit(unsigned int conversionFactor)
: conversionFactor(conversionFactor)
{}
private:
unsigned int conversionFactor;
};
然后,我们让LengthUnit
和VolumeUnit
分别继承自Unit
:
struct LengthUnit : Unit
{
// 允许用户使用的长度 Unit 对象
static LengthUnit getMile() { return MILE_FACTOR; }
static LengthUnit getYard() { return YARD_FACTOR; }
static LengthUnit getFeet() { return FEET_FACTOR; }
private:
enum
{
FEET_FACTOR = 1,
YARD_FACTOR = 3 * FEET_FACTOR,
MILE_FACTOR = 1760 * YARD_FACTOR
};
};
struct VolumeUnit : Unit
{
static Unit getTbsp() { return TBSP_FACTOR; }
static Unit getOz() { return OZ_FACTOR; }
private:
enum
{
TBSP_FACTOR = 1,
OZ_FACTOR = 2 * TBSP_FACTOR
};
};
然后我们用LengthUnit
和VolumeUnit
分别实例化Quantity
模板,从而得到两个仅仅共享代码,却无法相互操作的两个类:Length
和Volume
。
typedef Quantity<LengthUnit> Length;
typedef Quantity<VolumeUnit> Volume;
另外,通过这样的方法,我们不仅避免了两种体系之间互操作的问题,还将两个体系从代码上也清晰的分割为相互独立的两个部分。
总结
本文通过几个不同的C++
语言的例子,来说明预防胜于治疗思想对于提高系统健壮性的重要性。针对不同的语言,解决方案或许不同,但本文在各个例子中思考过程才是本文更想给读者带来的价值。