预防胜于治疗:MAKE POSSIBLE IMPOSSIBLE

错误/异常处理,一直是程序员痛恨,却无法摆脱的梦魇。如果一个系统中仅包含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):函数的调用者有义务确保传入的引用非空。否则,空引用引起的崩溃应该由调用者负责。

如果你仍然对此持有异议,那么不妨设想一下:让我们重新回到指针时代,看看会发生什么。

指针事实上有三种状态:

  1. 空:空指针
  2. 有效:指向一个合法对象
  3. 非法:指向一个未知世界

而引用却只有两种状态:

  1. 有效:引用了一个合法对象
  2. 非法:引用了一个非法对象(包括空对象

如果一个函数的某个参数为指针类型,那么作为函数的实现者,你也只能检测指针是否为空,却永远无法保证客户是否会传入一个非法指针。所以,保证指针的合法性(无论是否为空),当然是调用者的责任。

由此不难得出结论:保证一个引用不会是非法引用也应该是调用者的责任

事后条件

而当一个函数的返回值是一个引用时,这样的声明则明确定义了一个事后条件(Post Condition)。而保证引用的合法性,就成了函数实现者的义务。

// 函数实现者必须保证返回值的合法性
Object& f();

所以,实现者必须保证返回的对象不能是个临时对象。比如:

Object& getObject()
{
  // 不要这样做 
  Object object; 
  // ...
  return object;
}

所以,也要避免可能造成内存泄漏。比如:

Object& getObject()
{
   return *(new Object); 
}

而返回引用的优势则在于:它让所有的客户代码无须再进行相关的错误处理。

// 无须判断空指针 
getObject().f();

何时必须使用指针

由于引用比指针更加严格,所以,并非在所有场景下引用都可以代替指针。下面列出了几种常见理由(但并非全部)。

空指针是一种合法状态

指针引用貌似很像,但事实上存在在本质的语义差别:

  • 指针是MaybeOptional语义;
  • 引用是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;
}

这样的实现方式至少有两个问题:

  1. 内存分配失败的可能;
  2. 由于内存分配可能失败,所有的客户代码为了安全,都必须检查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;
};

然后,我们让LengthUnitVolumeUnit分别继承自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
  }; 
};

然后我们用LengthUnitVolumeUnit分别实例化Quantity模板,从而得到两个仅仅共享代码,却无法相互操作的两个类:LengthVolume

typedef Quantity<LengthUnit> Length;
typedef Quantity<VolumeUnit> Volume;

另外,通过这样的方法,我们不仅避免了两种体系之间互操作的问题,还将两个体系从代码上也清晰的分割为相互独立的两个部分。

总结

本文通过几个不同的C++语言的例子,来说明预防胜于治疗思想对于提高系统健壮性的重要性。针对不同的语言,解决方案或许不同,但本文在各个例子中思考过程才是本文更想给读者带来的价值。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 193,968评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,682评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,254评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,074评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,964评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,055评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,484评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,170评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,433评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,512评论 2 308
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,296评论 1 325
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,184评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,545评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,150评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,437评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,630评论 2 335

推荐阅读更多精彩内容