Item 35:考虑虚函数的其他替代设计 Effective C++笔记

C++ 策略模式 模板方法 虚函数 函数指针 JavaScript traits

Item 35: Consider alternatives to virtual functions.

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

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

NVI实现模板方法模式

模板方法设计模式:我们知道实现某个业务的步骤,但具体算法需要子类分别实现。

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

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

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

上述C++代码也有奇怪的地方,你可能已经发现了。doHealthValue在子类中是不可调用的,然而子类却重写了它。 但C++允许这样做是有充分理由的:父类拥有何时(when)调用该接口的权利;子类拥有如何(how)实现该接口的权利。

有时为了继承实现方式,子类虚函数会调用父类虚函数,这时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。 弱化的封装和更灵活的策略是一个需要权衡的设计问题,取决于实际问题中动态策略的需求有多大。

tr1::function实现策略模式

如果你已经习惯了模板编程,可能会发现函数指针实现的策略模式太过死板。 为什么不能接受一个像函数一样的东西呢(比如函数对象)?为什么不能是一个成员函数呢?为什么一定要返回int而不能是其他兼容类型呢?

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

现在tr1在C++11标准中已经被合并入std命名空间啦(叫做多态函数对象包装器),不需要std::tr1::function了,可以直接写std::function

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,第一个参数是currentLevelthis_1,即&currentLevel(细节略过啦!,这里的重点在于成员函数也可以传入!)。

如果你写过JavaScript你会发现这就是Function.prototype.bind嘛!

经典的策略模式

可能你更关心策略模式本身而不是上述的这些实现,现在我们来讨论策略模式的一般实现。 在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;
};

熟悉策略模式的人一眼就能看出来上述代码是策略模式的经典实现。可以通过继承HealthCalcFunc很方便地生成新的策略。

Harttle

致力于简单的、一致的、高效的前端开发

看看这个?