预防胜于治疗:Make Possible Impossible

C++ · godsme · 于 发布 · 760 次阅读
816

错误/异常处理,一直是程序员痛恨,却无法摆脱的梦魇。如果一个系统中仅包含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语义。

因而,一些时候,空指针也是一种合法状态(正如Optional语义的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();

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();

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 Length& 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 Tbsp 呢?

哦,长度容量是两种不同的体系。我们原来忘了在判断相等时对两种体系进行区分。

作为富有经验的程序员,这个难不倒我们:只需要为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++语言的例子,来说明预防胜于治疗思想对于提高系统健壮性的重要性。事实上,针对不同的语言,解决方案或许不同,因而本文在各个例子中思考过程才是本文想给读者带来的价值。


「软件匠艺社区」旨在传播匠艺精神,通过分享好的「工作方式」和「习惯」以帮助程序员更加快乐高效地编程。
本帖已被设为精华帖!
暂无回复。
需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。