预防胜于治疗:MAKE POSSIBLE IMPOSSIBLE

作者: _袁英杰_ | 来源:发表于2016-06-18 23:19 被阅读1026次

    错误/异常处理,一直是程序员痛恨,却无法摆脱的梦魇。如果一个系统中仅包含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++语言的例子,来说明预防胜于治疗思想对于提高系统健壮性的重要性。针对不同的语言,解决方案或许不同,但本文在各个例子中思考过程才是本文更想给读者带来的价值。

    相关文章

      网友评论

      • 特大萝卜:再回过来看这篇文章,仍然很有感触。
        有个疑问:如果某个系统在初始化时候需要实例化一个单实例,系统退出时候需要释放单实例,那么是否这个对象就不应该设计为单实例?
        _袁英杰_:@特大萝卜 子系统和其它系统在一个进程里吗?
        特大萝卜:@_袁英杰_ 子系统退出.....之前就是很多这种场景导致内存泄露。:disappointed_relieved::disappointed_relieved:
        _袁英杰_:@特大萝卜 系统退出时,如果是静态静态分配,其析构函数会被自动调用。如果是动态分配,则无法保证这一点。不过,一般来讲,系统都要退出了,一个单例是否释放或析构已经不重要了。操作系统会保证将进程的一切资源回收。
      • 尉刚强:特别是前面的防御式编程,倍受启发
      • MagicBowen:太喜欢这篇了,很受启发!

      本文标题:预防胜于治疗:MAKE POSSIBLE IMPOSSIBLE

      本文链接:https://www.haomeiwen.com/subject/swlbdttx.html