引子

JSON 中有多种类型,比如数字(int/double/uint)/bool/string/数组/对象,C++ 中解析 json 开源库有 nlohmann::json。如果实现一套 json 解析能力,其中关键的一个部分就是如何定义一个类来表示 json,同时提供各种接口来修改 json 中各种类型的值。

chromium 中的 base 库中 base::Value 就是这样的一个实现。非常有趣的是这个类 chromium 一直以来都不断的进行设计的调整,这一定程度代表 chromium 对代码设计的不同思考。

nlohmann 2015年发布的第一个版本,而 chromium 在 1.0 版本(2008 年)就已经实现了 base::Value。

下面会尝试根据 chromium 不同版本对这个类的实现分析 chromium 的代码设计思路。

版本迭代

第一个版本

先思考一下,通过 base::Value 表示多种类型,最直觉的实现是什么?是不是使用 c++多态,搞一个基类,然后不同类型基于这个基类派生一个新的类表示新的类型呢?这样基类指针就可以表示 JSON 中所有类型啦!

恭喜你,chromium 也是这么想的 🤨。这个版本 base/values.cc 文件仅仅只有 580 行,来简化一下代码如下:

class Value {
public:
  // 空实现
  virtual bool GetAsBoolean(bool* out_value) const;
  virtual bool GetAsInteger(int* out_value) const;
  virtual bool GetAsReal(double* out_value) const;
  virtual bool GetAsString(std::wstring* out_value) const;
  
private:
  Type type_;
}

// 基础数据类型
class FundamentalValue : public Value {
 public:
  // Subclassed methods
  virtual bool GetAsBoolean(bool* out_value) const;
  virtual bool GetAsInteger(int* out_value) const;
  virtual bool GetAsReal(double* out_value) const;
  virtual Value* DeepCopy() const;
  virtual bool Equals(const Value* other) const;

 private:
  union {
    bool boolean_value_;
    int integer_value_;
    double real_value_;
  };
};


// 字符串
class StringValue : public Value {
 public:
  // Subclassed methods
  bool GetAsString(std::wstring* out_value) const {
      if (out_value)
        *out_value = value_;
      return true;
  }
 private:
  std::wstring value_;
};


// 对象
typedef std::map<std::wstring, Value*> ValueMap;
class DictionaryValue: public Value {
public:
  bool GetBoolean(const std::wstring& path, bool* out_value) const;
  bool GetInteger(const std::wstring& path, int* out_value) const;
  bool GetReal(const std::wstring& path, double* out_value) const;
  bool GetString(const std::wstring& path, std::wstring* out_value) const;
  bool GetBinary(const std::wstring& path, BinaryValue** out_value) const;
  bool GetDictionary(const std::wstring& path,
                     DictionaryValue** out_value) const;
  bool GetList(const std::wstring& path, ListValue** out_value) const;
  
private:
  ValueMap dictionary_;
};

// 数组
typedef std::vector<Value*> ValueVector;
class ListValue: public Value{
public:
  bool Get(size_t index, Value** out_value) const;
  bool GetDictionary(size_t index, DictionaryValue** out_value) const{
      Value* value;
      bool result = Get(index, &value);
      if (!result || !value->IsType(TYPE_DICTIONARY))
        return false;
  
      if (out_value)
        *out_value = static_cast<DictionaryValue*>(value);
  }

    private:
    ValueVector list_;
};

这个代码设计比较简单,但有一些问题:

  1. base::Value 提供的虚接口都是空实现,base::Value 本身基本上就是一个空壳子,并且派生类也没有 override
  2. ValueVector / ValueMap 都是直接用裸指针存储的,生命周期非常不明确
对于 List/Dict 设计蕴含着一些“递归”的思想,比如如果 Value 内容是 Dict,则它内部存储的内容是 std::map<std::string, Value>,而 Value 本身也可以表示多种类型。

第二个版本

在 2017 年,chromium 开始对 base::Value 进行重构

简化后的代码如下,有以下几点变化:

  1. 不再使用派生子类的方式,而是 base::Value 中直接存储 6 种类型的数据,然后用一个 Type 来标识当前是哪种类 1 型。
  2. 基于第一点,base::Value 上直接实现了获取基础类型的接口以及 Dict/List 相关接口

    1. Dict 接口只保留了一个 FindKey 和 FindPath,不再提供之前的 GetInteger(实际上应该叫 FindInterger 更准确)这种根据 key 直接查询特定数据类型的值
    2. List 接口也只保留了 GetList 接口,移除了获取迭代器以及通过 Index 方式获取元素值的接口
  3. 废弃了之前 GetAsXXX 的接口风格,之前是通过参数 out 指针输出,在这次全改成直接返回值返回了,减少指针带来的风险,接口使用起来也更简单一些
注意,这个版本里没有GetDict的接口,base::Value本身就提供了Dict对应的查询接口,如果base::Value上不提供Find 接口,那外部需要获取到map后手动去查询,有点麻烦。但是对于List,却提供了GetList 接口,就有点别扭
class Value {
public:
  using BlobStorage = std::vector<char>;
  using DictStorage = base::flat_map<std::string, std::unique_ptr<Value>>;
  using ListStorage = std::vector<Value>;
  // 简单类型接口
  bool GetBool() const {
      if (is_bool()){
          return bool_value_;
      }
  }
  int GetInt() const;
  double GetDouble() const;  // Implicitly converts from int if necessary.
  const std::string& GetString() const;
  const BlobStorage& GetBlob() const;
  
  // List 接口
  ListStorage& GetList() const;
  
  // Dict
  dict_iterator DictEnd();
  dict_iterator_proxy DictItems();
  dict_iterator FindKey(StringPiece key);

private:
    enum class Type{
        kBool,
        kInt,
        kBlob,
        kString,
        kDict,
        kList
    };
     union {
        bool bool_value_;
        int int_value_;
        double double_value_;
        ManualConstructor<std::string> string_value_;
        ManualConstructor<BlobStorage> binary_value_;
        ManualConstructor<DictStorage> dict_;
        ManualConstructor<ListStorage> list_;
      };
};

这个版本核心变动,一是解决裸指针,二是淘汰了之前通过多态派生子类方式实现多种类型,让 Value 本身直接来表示多种类型。

  1. 因为废弃了 DictionaryValue / ListValue / FundamentalValue,base::Value 集所有功能于一身,但是 List/Dict 的接口只保留了基础接口,如果某个 key 已知是特定的数据类型,则只能先根据 key 拿到 Value,然后外部再去判断 Value 的类型,外部使用起来会比较麻烦
  2. 对外直接暴露了 ListStorage&/DictStorage&(base::flat_map<std::string, base::Value&>std::vector<base::Value&>&),外部可以直接获取该类型增删元素,后续重构如果更换storage类型则成本比较高
Note:chromium 在 2020 年使用 variant 替代了 union,这个对整体设计没有影响,只不过代码上会更优雅一点点

第三个版本

第二个版本中第一个问题,可能chromium最初就是这么设计的, 不想提供冗余的接口。但事实上是用起来太麻烦了。有些时候代码设计的好坏的评价之一就是业务方使用起来方不方便。

chromium 2019-01-08 的提交里提供一些扁平的接口:

  • 在 base::Value 上加一些 FindXXXKey 的 Dict 操作接口,以便外部不需要先通过 key 获取到 value,然后再去判断 value 类型了
class Value {
public:
  // the value is not found or doesn't have the type specified in the
  // function's name.
  base::Optional<bool> FindBoolKey(StringPiece key) const;
  base::Optional<int> FindIntKey(StringPiece key) const;
  base::Optional<double> FindDoubleKey(StringPiece key) const;

  // |FindStringKey| returns |nullptr| if value is not found or not a string.
  const std::string* FindStringKey(StringPiece key) const;
}

后续又添加了List等扁平接口:

class Value {
public:
  void Append(bool value);
  void Append(int value);
  void Append(double value);

  void Append(const char* value);
  void Append(StringPiece value);
  void Append(std::string&& value);
  void Append(const char16_t* value);
}

至此随着迭代,越来越多的Dict / List的接口被复制到了 base::Value 上,代码可读性和维护的难度变大了。

第四个版本

第一个版本中,chromium 通过继承来派生多个功能,第二个版本中又移除了继承,将所有功能集一身,但是会发现接口繁杂的问题。这些问题 chromium 在 2022 年的 MR 中指出现有设计的几个问题:

  1. 代码膨胀:直接使用底层的容器类型(如base::flat_map<std::string, base::Value>std::vector<base::Value>)导致了有大量重复的代码。
  2. 类型安全检查问题:外部使用的是base::Value单个类型,但是提供了“扁平便利”接口(比如FindInt)需要外部先确保是dict类型才能使用,这缺少了类型安全检查。
  3. 封装问题:暴露底层容器类型使未来的实现细节重构变得更加困难。(GetList直接返回了std::vector<base::Value> ,如果未来List内部数据类型变化,则所有使用依赖该接口的地方都需要修改),这不符合设计原则中的“开闭原则”。

2022 年,chromium 在此对 base::Value 进行重构。其核心是将 Dict/List 内容以及接口重新封装到单独的类中去。

这一幕看似眼熟,但实际和第一版本设计思路并不完全一致。

简化后的代码如下,这个代码非常清晰,对后续的扩展也更方便了:

class Value {
public:
  // 基础类型接口
  absl::optional<bool> GetIfBool() const;
  absl::optional<int> GetIfInt() const;
  // Returns a non-null value for both `Value::Type::DOUBLE` and
  // `Value::Type::INT`, converting the latter to a double.
  absl::optional<double> GetIfDouble() const;
  const std::string* GetIfString() const;
  std::string* GetIfString();
  const BlobStorage* GetIfBlob() const;
  // Dict接口
  Dict* GetIfDict();
  // List接口
  List* GetIfList();
  
  // Dict
  class Dict {
   public:
    absl::optional<bool> FindBool(StringPiece key) const;
    absl::optional<int> FindInt(StringPiece key) const;
    absl::optional<double> FindDouble(StringPiece key) const;
    const std::string* FindString(StringPiece key) const;
    const BlobStorage* FindBlob(StringPiece key) const;
    Dict* FindDict(StringPiece key);
    List* FindList(StringPiece key);
  
    private:
     flat_map<std::string, std::unique_ptr<Value>> storage_;
  };
  
  // List
  class List{
    public:
     iterator begin();
     iterator end();
   
    private:
     std::vector<Value> storage_;
  };
private:
  absl::variant<absl::monostate,
                bool,
                int,
                DoubleStorage,
                std::string,
                BlobStorage,
                Dict,
                List>
      data_;

};

base::Value 的第一版本设计里,基类 base::Value 只是一个空壳子,使用了继承方式派生了不同类型,不符合设计原则中的“里氏替换原则”,即子类之间是可以互相替换而不影响主要功能,显然第一版子类和父亲的接口完全都不一样了。

小结

base::Value 的重构过程也表示在某些场景下,组合比继承更适合。 在《重构》书中,也提到了“以委托(组合)取代子类”的重构手法。

继承不是什么坏的设计,它能让子类具体父类不同的逻辑,但是它可能会被滥用。其中“里氏替换原则”是一个很好的原则帮我们判断当前的继承是否是合适的。实际开发继承可能会遇到两个问题,一是父类的虚函数改动,子类无法感知,可能会导致意外结果,另一方面是子类的权限很大, 很容易随着迭代逐渐和父类差异过大,导致父类无法约束子类的行为(这一点可以通过约束可重载函数的范围)。

如果你有任何不同的看法,欢迎在评论区一起讨论 ☕️

最后修改:2024 年 07 月 07 日
喜欢我的文章吗?
别忘了点赞或赞赏,让我知道创作的路上有你陪伴。