Item 35:考虑虚函数的其他替代设计

替代虚函数实现的几种方案

比如你在开发一个游戏,每个角色都有一个 healthValue() 方法。很显然你应该把它声明为虚函数,可以提供默认的实现,让子类去自定义它。 这个设计方式太显然了你都不会考虑其他的设计方法。但有时确实存在更好的,本节便来举几个替代的所涉及方法。

  • 非虚接口范式(NVI idiom)可以实现模板方法设计模式,用非虚函数来调用更加封装的虚函数。
  • 用函数指针代替虚函数,可以实现策略模式。
  • 用 function 代替函数指针,可以支持所有兼容目标函数签名的可调用对象。
  • 用另一个类层级中的虚函数来提供策略,是策略模式的惯例实现。

NVI 实现模板方法模式

使用非虚接口(Non-Virtual-Interface Idiom)可以实现模板方法模式。比如 healthValue 声明为普通函数,它调用一个私有虚函数 doHealthValue 来实现。 实现起来是这样的:

class GameCharacter{
public:
    //@ 子类不应重新定义该方法
    int healthValue() const{
        //@ do sth. before
        int ret = doHealthValue();
        //@ do sth. after
        return ret;
    }
private:
    //@ 子类可以重新定义该方法
    virtual int doHealthValue() const{
        //@ 默认实现
    }
}

NVI Idiom的好处在于,在调用 doHealthValue 前可以做一些设置上下文的工作,调用后可以清除上下文。 比如在调用前给互斥量(mutex)加锁、验证前置条件、类的不变式;调用后给互斥量解锁、验证后置条件、类的不变式等。

doHealthValue 在子类中是不可调用的,然而子类却重写了它。 但 C++ 允许这样做是有充分理由的:

  • 父类拥有何时调用该接口的权利;
  • 子类拥有如何实现该接口的权利。

有时为了继承实现方式,子类虚函数会调用父类虚函数,这时 doHealthValue 就需要是 protected 了。 有时(比如析构函数)虚函数还必须是 public,那么就不能使用NVI了。

函数指针实现策略模式

上述的 NVI 随是实现了模板方法,但事实上还是在用虚函数。我们甚至可以让 healthValue() 完全独立于角色的类,只在构造函数时把该函数作为参数传入。

class GameCharacter;
 
int defaultHealthCalc(const GameCharacter& gc);
 
class GameCharacter{
public:
    typedef int (*HealthCalcFunc)(const GameCharacter&);
    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc): healthFunc(hcf){}
    int healthValue() const{
        return healthFunc(*this);
    }
private:
    HealthCalcFunc healthFunc;
}

这便实现了策略模式。可以在运行时指定每个对象的生命值计算策略,比虚函数的实现方式有更大的灵活性:

同一角色类的不同对象可以有不同的 healthCalcFunc。只需要在构造时传入不同策略即可。
角色的 healthCalcFunc 可以动态改变。只需要提供一个 setHealthCalculator 成员方法即可。
我们使用外部函数实现了策略模式,但因为 defaultHealthCalc 是外部函数,所以无法访问类的私有成员。 如果它通过 public 成员便可以实现的话就没有任何问题了,如果需要内部细节:

我们只能弱化 GameCharacter 的封装。或者提供更多 public 成员,或者将 defaultHealthCalc 设为 friend。 弱化的封装和更灵活的策略是一个需要权衡的设计问题,取决于实际问题中动态策略的需求有多大。

function 实现策略模式

使用 function 代替函数指针!function 是一个对象, 他可以保存任何一种类型兼容的可调用的实体(callable entity)例如函数对象、成员函数指针等。 看代码:

class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);
 
class GameCharacter{
public:
    typedef std::function<int (const GameCharacter&)> HealthCalcFunc;
    explicit GameCaracter(HealthCalcFunc hcf = defaultHealthCalc): healthCalcFunc(hcf){}
    int healthValue() const{
        return healthFunc(*this);
    }
private:
    HealthCalcFunc healthFunc;
};

注意 std::function 的模板参数是 int (const GameCharacter&),参数是 GameCharacter 的引用返回值是 int, 但 healthCalcFunc 可以接受任何与该签名兼容的可调用实体。即只要参数可以隐式转换为 GameCharacter 返回值可以隐式转换为 int 就可以。 用 function 代替函数指针后客户代码可以更加灵活:

// 类型兼容的函数
short calcHealth(const GameCharacter&);
// 函数对象
struct HealthCalculator{
    int operator()(const GameCharacter&) const{...}
};
// 成员函数
class GameLevel{
public:
    float health(const GameCharacter&) const;
};

无论是类型兼容的函数、函数对象还是成员函数,现在都可以用来初始化一个 GameCharacter 对象:

GameCharacter evil, good, bad;
// 函数
evil(calcHealth);                       
// 函数对象
good(HealthCalculator());
// 成员函数
GameLevel currentLevel;
bad(std::bind(&GameLevel::health, currentLevel, _1));

GameLevel::health 接受一个参数 const GameCharacter&, 但事实上在运行时它是需要两个参数的,const GameCharacter& 以及 this。只是编译器把后者隐藏掉了。 那么std::bind 的语义就清楚了:首先它指定了要调用的方法是 GameLevel::health,第一个参数是 currentLevel,this 是_1,即 &currentLevel。

经典的策略模式

在 UML 表示中,生命值计算函数 HealthCalcFunc 应当定义为一个类,拥有自己的类层级。 它的成员方法 calc 应当为虚函数,并在子类可以有不同的实现。

class HealthCalcFunc{
public:
    virtual int calc(const CameCharacter& gc) const;
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter{
public:
    explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc): pHealthCalc(phcf){}
    int healthValue() const{
        return pHealthCalc->calc(*this);
    }
private:
    HealthCalcFunc *pHealthCalc;
};

总结

  • 可选的虚拟函数的替代方法包括 NVI 惯用法和策略模式的各种变化形式。NVI 惯用法本身是模板方法模式的一个实例。
  • 将一个机能从一个成员函数中移到类之外的某个函数中的一个危害是非成员函数没有访问类的非公有成员的途径。
  • function 对象的行为类似泛型化的函数指针。这样的对象支持所有兼容于一个给定的目标特征的可调用实体。
posted @ 2020-02-26 16:58  刘-皇叔  阅读(411)  评论(0编辑  收藏  举报