9.2. 参数传递的痛点分析

每个在足够大型的 C++ 系统中工作过的人,最终都曾需要为函数添加参数。为了避免破坏现有代码,新参数通常会赋予一个默认值,以保持原有的功能。这种方法第一次使用时效果很好,第二次也还可以,但每次函数调用时都需要去数参数个数。长函数声明还会带来其他问题,如果想要一个更好的解决方案,在尝试解决之前,花些时间理解这些问题是值得的。在进入解决方案之前,本节将首先对问题进行更深入的分析。

9.2.1 参数过多有什么问题?

无论最初编写代码时就是以传递大量参数的方式,还是随着项目发展逐渐演变成这样,这种代码都十分脆弱,容易因开发者的疏忽而出现问题。主要问题在于,通常会有许多相同类型的参数,很容易在计数或传递时出错。试想设计一款文明建设类游戏 —— 当玩家创建一座新城市时,会相应地构造一个对象。玩家可以选择在城市中建造哪些设施,而游戏则会为可用资源设置相应的选项:

class City {
public:
  enum center_t { KEEP, PALACE, CITADEL };
  City(size_t number_of_buildings,
    size_t number_of_towers,
    size_t guard_strength,
    center_t center,
    bool with_forge,
    bool with_granary,
    bool has_fresh_water,
    bool is_coastal,
    bool has_forest);
  ...
};

看起来已经把所有事情都安排妥当了。现在开始游戏,为每位玩家创建一座城市,城市里有一座主堡、一座瞭望塔、两座建筑和一支卫队:

City Capital(2, 1, 1, City::KEEP,
             false, false, false, false);

各位,能看出其中的错误吗?编译器肯定可以 —— 参数数量不足。由于编译器不允许在此处出错,所以问题不大,只需添加 has_forest 的参数即可。此外,假设游戏将城市放置在河边,所以现在城市有水源了:

City Capital(2, 1, 1, City::KEEP,
             false, true, false, false, false);

这很简单……哎呀!我们现在让城市坐落在河边,却没有淡水(那条河里到底是什么水?)。不过,多亏了意外获得的免费粮仓,市民们至少不会挨饿。这个错误 —— 将 true 值传递给了错误的参数 —— 只能在调试过程中才能发现。此外,这段代码非常冗长,可能会发现自己一遍又一遍地输入相同的值。也许游戏默认会尝试将城市放置在河流和森林附近?好吧,那就这样:

class City {
public:
  enum center_t { KEEP, PALACE, CITADEL };
  City(size_t number_of_buildings,
    size_t number_of_towers,
    size_t guard_strength,
    enter_t center,
    bool with_forge,
    bool with_granary,
    bool has_fresh_water = true,
    bool is_coastal = false,
    bool has_forest = true);
  ...
};

现在,让我们回到最初尝试创建城市的地方 —— 现在代码能够编译通过了,尽管少了一个参数,但我们却浑然不知,误将参数数量数错了。游戏大获成功,在下一个更新版本中,我们获得了一个令人兴奋的新建筑 —— 一座寺庙!因此,需要为构造函数添加一个新的参数。将这个新参数放在 with_granary 之后,与其他建筑相关的参数放在一起,再置于地形特征参数之前,这合乎逻辑。但这样一来,就必须修改每一个调用 City 构造函数的地方。更糟糕的是,这很容易出错,因为对于开发者和编译器来说,表示“没有寺庙”的 false 和表示“没有淡水”的 false 看起来完全一样。这个新参数必须准确地插入到,一长串外观极为相似的值中的正确位置。

当然,现有的游戏代码在没有寺庙的情况下也能正常运行,因此寺庙只在新的更新代码中才需要。除非必要,不干扰现有代码是有一定价值的。如果把新参数添加到参数列表末尾,并为其设置默认值,就可以实现。这样一来,所有未修改的构造函数调用,仍然会创建出和以前完全相同的城市:

class City {
public:
  enum center_t { KEEP, PALACE, CITADEL };
  City(size_t number_of_buildings,
    size_t number_of_towers,
    size_t guard_strength,
    center_t center,
    bool with_forge,
    bool with_granary,
    bool has_fresh_water = true,
    bool is_coastal = false,
    bool has_forest = true,
    bool with_temple = false);
  ...
};

现在,我们却让短期的便利性主导了长期的接口设计。参数不再具有合理的逻辑分组,从长远来看,出错的可能性反而更高了。此外,并未彻底解决无需修改的代码的问题 —— 下一次版本更新又增加了一种新的地形“沙漠”,随之而来的是另一个参数:

class City {
public:
  enum center_t { KEEP, PALACE, CITADEL };
  City(size_t number_of_buildings,
    size_t number_of_towers,
    size_t guard_strength,
    center_t center,
    bool with_forge,
    bool with_granary,
    bool is_coastal = false,
    bool has_forest = true,
    bool with_temple = false,
    bool is_desert = false);
  ...
};

当开了这个先例,所有后续在末尾添加的新参数都必须赋予默认值。为了在沙漠中创建一座城市,还必须明确指定它是否拥有寺庙。这在逻辑上并无必要,但我们却被接口演变的过程所束缚。考虑到使用的许多类型彼此之间可以相互转换时,情况就变得更糟了:

City Capital(2, 1, false, City::KEEP,
             false, true, false, false, false);

这将创建一座拥有零支卫队的城市,而不是开发者在将第三个参数设为 false 时原本期望禁用的其他功能。即使是枚举(enum)类型也无法提供完全的保护。有读者可能已经注意到,所有新城市通常都从一座主堡(keep)开始,因此将其设为默认值也是合理的:

// Example 01
class City {
public:
  enum center_t { KEEP, PALACE, CITADEL };
  City(size_t number_of_buildings,
    size_t number_of_towers,
    size_t guard_strength,
    center_t center = KEEP,
    bool with_forge = false,
    bool with_granary = false,
    bool has_fresh_water = true,
    bool is_coastal = false,
    bool has_forest = true,
    bool with_temple = false,
    bool is_desert = false);
  ...
};

现在,不必输入那么多参数,甚至可能避免了一些错误(如果不写参数,自然就不会写错顺序)。但新的错误仍然可能发生:

City Capital(2, 1, City::CITADEL);

我们刚刚“雇佣”的两支卫队(因为 CITADEL 的数值是 2)会发现,在低级的主堡(这是我们本打算更改但未更改的)里,他们的空间严重不足。C++11 的 enum class 提供了更好的保护,因为每个枚举类都是不同的类型,无法隐式转换为整数,但根本性的问题依然存在。

所以,将大量值作为独立参数传递给 C++ 函数存在两个问题。首先,会导致函数声明和调用变得非常冗长,容易出错。其次,如果需要添加一个值或更改参数类型,就需要修改大量的代码。而解决这两个问题的方法,在 C++ 诞生之前就已经存在了,源自 C 语言 —— 使用聚合类型,即结构体,将多个值组合成一个单一的参数。

9.2.2 聚合参数

使用聚合参数时,创建一个包含所有值的结构体或类,而不是为每个值单独添加一个参数。不必局限于使用单一的聚合体;例如,城市可以接受多个结构体,一个用于游戏设置的所有与地形相关的特性,另一个用于玩家直接控制的所有特性:

struct city_features_t {
  size_t number_of_buildings = 1;
  size_t number_of_towers = 0;
  size_t guard_strength = 0;

  enum center_t { KEEP, PALACE, CITADEL };
  center_t center = KEEP;

  bool with_forge = false;
  bool with_granary = false;
  bool with_temple = false;
};

struct terrain_features_t {
  bool has_fresh_water = true;
  bool is_coastal = false;
  bool has_forest = true;
  bool is_desert = false;
};

class City {
public:
  City(city_features_t city_features,
  terrain_features_t terrain_features);
  ...
};

这种解决方案具有诸多优势。首先,为参数赋值可以显式地通过名称进行,非常清晰明了(当然也相当冗长):

city_features_t city_features;
city_features.number_of_buildings = 2;
city_features.center = city_features::KEEP;
...
terrain_features_t terrain_features;
terrain_features.has_fresh_water = true;
...
City Capital(city_features, terrain_features);

能够更清楚地看出每个参数的值是什么,出错的可能性也大大降低(另一种方式,即对结构体进行聚合初始化,只是把问题从一处初始化转移到了另一处)。如果需要添加新功能,大多数情况下只需向其中一个聚合类型添加一个新的数据成员即可。只有真正处理新参数的代码才需要更新;所有那些仅仅传递和转发参数的函数和类则完全不需要修改。甚至可以为聚合类型提供默认值,从而为所有参数提供默认值,就像在上一个示例中所做的那样。

总体而言,这是解决函数参数过多问题的一个极佳方案,但也有一个缺点:必须显式地逐行创建和初始化这些聚合体。在许多情况下,这种做法是可行的,特别是当这些类和结构体代表需要长期保存的状态变量时。但是,当它们纯粹用作参数容器时,就会产生不必要的冗长代码,首先就是聚合变量必须有一个名称。实际上并不需要这个名称,因为我们只打算用它来调用一次函数,但却不得不编造一个名字。此时,直接使用临时变量似乎更具吸引力:

struct city_features_t {
  size_t number_of_buildings = 1;
  size_t number_of_towers = 0;
  size_t guard_strength = 0;

  enum center_t { KEEP, PALACE, CITADEL };
  center_t center = KEEP;

  bool with_forge = false;
  bool with_granary = false;
  bool with_temple = false;
};

struct terrain_features_t {
  bool has_fresh_water = true;
  bool is_coastal = false;
  bool has_forest = true;
  bool is_desert = false;
};

City Capital({2, 1, 0, KEEP, true, false, false},
             {true, false, false, true});

这样做虽然可行,但却让我们回到了原点,再次面对一个带有长长一串布尔参数、极易混淆的函数。我们遇到的根本问题是:C++ 函数使用的是位置参数,而试图实现的是能够按名称来指定参数。聚合对象在很大程度上是作为副作用解决了这个问题,如果从整体设计角度出发,将一组值收集到一个类中有其益处,当然应该这样做。然而,如果仅仅为了解决“命名参数”这一特定问题,而没有其他更持久的理由将这些值组合在一起,那么聚合对象就显得力不从心了。接下来,我们将探讨如何弥补这一缺陷。