从 base::Value 接口变更看 chromium 代码设计

举报
卢衍飞 发表于 2024/11/20 14:45:57 2024/11/20
【摘要】 引子JSON 中有多种类型,比如数字(int/double/uint)/bool/string/数组/对象,C++ 中解析 json 开源库有 nlohmann::json。如果实现一套 json 解析能力,其中关键的一个部分就是如何定义一个类来表示 json,同时提供各种接口来修改 json 中各种类型的值。chromium 中的 base 库中 base::Value 就是这样的一个实现...

引子
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_;

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

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

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

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

Dict 接口只保留了一个 FindKey 和 FindPath,不再提供之前的 GetInteger(实际上应该叫 FindInterger 更准确)这种根据 key 直接查询特定数据类型的值
List 接口也只保留了 GetList 接口,移除了获取迭代器以及通过 Index 方式获取元素值的接口
废弃了之前 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_;
ManualConstructorstd::string string_value_;
ManualConstructor<BlobStorage> binary_value_;
ManualConstructor<DictStorage> dict_;
ManualConstructor<ListStorage> list_;
};
};
这个版本核心变动,一是解决裸指针,二是淘汰了之前通过多态派生子类方式实现多种类型,让 Value 本身直接来表示多种类型。

因为废弃了 DictionaryValue / ListValue / FundamentalValue,base::Value 集所有功能于一身,但是 List/Dict 的接口只保留了基础接口,如果某个 key 已知是特定的数据类型,则只能先根据 key 拿到 Value,然后外部再去判断 Value 的类型,外部使用起来会比较麻烦
对外直接暴露了 ListStorage&/DictStorage&(base::flat_map<std::string, base::Value&>和std::vectorbase::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 中指出现有设计的几个问题:

代码膨胀:直接使用底层的容器类型(如base::flat_map<std::string, base::Value>和std::vectorbase::Value)导致了有大量重复的代码。
类型安全检查问题:外部使用的是base::Value单个类型,但是提供了“扁平便利”接口(比如FindInt)需要外部先确保是dict类型才能使用,这缺少了类型安全检查。
封装问题:暴露底层容器类型使未来的实现细节重构变得更加困难。(GetList直接返回了std::vectorbase::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 的重构过程也表示在某些场景下,组合比继承更适合。 在《重构》书中,也提到了“以委托(组合)取代子类”的重构手法。

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

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

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。