博客 阅读需 7 分钟

探索现代 C++ 设计:MemCache++ 案例研究

分享本文
Exploring Modern C++ Design: MemCache++ Case Study

MemCache++是一个轻量级、类型安全、易于使用且功能完整的 Memcache 客户端。它由 C++ 狂热爱好者 Dean Michael Berris 开发,他目前在 Google 澳大利亚工作,同时也是 Google 派往 ISO C++ 委员会的代表团成员。

研究设计良好的库是提升 C++ 设计与实现技能的绝佳方式。本文的目标是探究让 memcache++ 易于理解和使用的一些设计选择。

命名空间模块化

命名空间是实现应用程序模块化的好方法。遗憾的是,这个特性在 C++ 项目中使用不足——随便看几个开源 C++ 项目就能明白这一点。而且,当我们搜索 C++ 命名空间的定义时,常见的定义是这样的:

A namespace defines a new scope. They provide a way to avoid name collisions.

通常,避免命名冲突被当作主要动机,而不是模块化——这与 C# 和 Java 不同,在那些语言中,命名空间更常用于组织应用程序的结构。不过,一些现代 C++ 库(如 Boost)使用命名空间把库组织得很好,并鼓励开发者使用它们。

memcache++ 中的命名空间模块化做得如何?

下面是 memcache++ 各命名空间之间的依赖图:

命名空间的使用主要有两个目的:

    • 对库进行模块化。
    • 隐藏细节,比如 “memcache::detail” 命名空间;如果我们想告诉库的使用者,他们不需要直接使用该命名空间中的类型,这种做法就非常有意思。在 C# 中,“internal” 关键字可以做到这一点,但在 C++ 中没有办法对库的使用者隐藏公有类型。

memcache++ 有效地使用了命名空间。不过,memcache 和 memcache::detail 之间存在一个依赖环。我们可以通过搜索 memcache 中被 memcache::detail 使用的类型来消除这个依赖环。

为此,我们可以执行以下 CQLinq 查询:

from t in Types where t.IsUsedBy("memcache.detail") 
&& t.ParentNamespace.Name=="memcache"
select new { t,t.TypesUsingMe }

下面是执行查询后的结果:

memcache dependencies graph

要消除这个依赖环,我们可以把 pool_directive 和 server_pool_test 移到 memcache 命名空间中。

在现代 C++ 代码中,使用最多的是哪种范式:泛型编程还是 OOP?

在 C++ 世界里,有两个流派非常流行:面向对象编程和泛型编程;每种方法都有自己的拥趸。这篇文章解释了两者之间的张力。

memcache++ 使用最多的是哪种范式?

为了回答这个问题,让我们先搜索泛型类型:

from t in Types where t.IsGeneric && !t.IsThirdParty select t 

那非泛型类型呢?

from t in Types where !t.IsGeneric && !t.IsGlobal && !t.IsNested 
&& !t.IsEnumeration  && t.ParentProject.Name=="memcache"
select t

几乎所有非泛型类型都是异常类;树状图视图能让我们更直观地了解它们所占的比例。

memcache treemap view

蓝色矩形代表 CQLinq 查询的结果。可以看到,库中只有极小一部分与非泛型类型相关。

最后,我们可以搜索泛型方法:

from m in Methods where m.IsGeneric && !m.IsThirdParty select m

可以看到,memcache++ 主要使用泛型,但这还不足以确认它遵循的是 C++ 中的泛型编程方法。要验证这一点,一个很好的指标是继承和动态多态的使用情况——OOP 大量依赖这两者,而在泛型方法中,继承的使用非常有限,动态多态则被避免。

让我们搜索拥有基类的类型。

from t in Types where t.BaseClasses.Count()>0 && !t.IsThirdParty 
&& t.ParentProject.Name=="memcache"
select t

异常类使用继承是正常的,但其他类呢?它们使用继承是为了实现动态多态吗?为了回答这个问题,让我们搜索所有的虚方法。

from  m in Methods where m.IsVirtual select m

只有异常类才有虚方法。

如果不使用动态多态,当我们需要让特定类表现出不同行为时,可以采用什么方法?

现代 C++ 方法中的常见解决方案是使用策略(policy)。下面是维基百科的简短定义:

"The central idiom in policy-based design is a class template(called the host class),taking several type parameters as input, which are instantiated with types selected by the user (called policy classes), each implementing a particular implicit interface (called a policy)."

memcache++ 在 memcache.policies 命名空间中拥有许多策略。

让我们看一个 memcache++ 的例子,以便更好地理解基于策略的设计。

memcache++ 使用 basic_handle 类型来实现所有命令,比如对缓存执行 add、set、get 和 delete。这个类是这样定义的:

    template <
        class threading_policy = policies::default_threading, 
        class data_interchange_policy = policies::binary_interchange,
        class hash_policy = policies::default_hash
    >
    struct basic_handle 

memcache++ 是线程安全的,在多线程环境中它必须管理同步;默认情况下 threading_policy 是 “default_threading”,此时不需要任何特殊处理。而对于多线程,使用的策略是 “boost_threading”。

让我们看看 connect 方法的实现。

    void connect(boost::uint64_t timeout = MEMCACHE_TIMEOUT) { 
       typename threading_policy::lock scoped_lock(*this);
       for_each(servers.begin(), servers.end(), connect_impl(service_, timeout));
    };

如果 threading_policy 是 “default_threading”,第一行不会有任何效果,因为锁的构造函数什么都不做。然而,如果是 boost_threading 策略,锁就会使用 Boost 在线程之间进行同步。

使用策略让我们能更灵活地实现不同的行为,同时保持相对容易理解和使用。

泛型仿函数

memcache++ 实现了许多与缓存交互的命令,比如 add、get、set 和 delete。命令模式(command pattern)非常适合这种情况。memcache++ 使用泛型仿函数实现这个模式;下面是一条获取所有仿函数的 CQLinq 查询:

from t in Types where t.Methods.Where(a=>a.IsOperator 
&& a.Name.Contains("()")).Count()>0
select t

仿函数把一次函数调用连同其状态封装起来,可用于将调用推迟到以后执行,充当回调。泛型仿函数比普通仿函数更灵活。

对外暴露的公共接口

一个库如何暴露其能力非常重要,因为它同时影响灵活性和易用性。为了弄清楚这一点,让我们搜索测试项目与 memcache++ 库之间的通信。

from m in Methods where m.IsUsedBy ("test")
select m 

测试项目主要使用泛型方法来调用 memcache++ 的功能。使用模板方法有什么好处?为什么不使用类或普通函数?

在 OOP 方法中,库的接口由类和函数组成;设计良好的库会使用抽象类作为契约来保持低耦合。这种方案很有意思,但也有一些缺点:

  • 接口会变得更复杂,而且可能频繁变动。为了说明这一点,让我们看看 memcache++ 暴露的 add 方法。如果不使用泛型方法,就必须添加许多方法——为每种特定类型(int、double、string……)各加一个。

泛型 add 方法声明为 add<T>,其中 T 是类型;在这种情况下,我们只需要一个方法,即使想支持另一种类型,接口也无需任何改动。

  • 接口的灵活性较差。例如,如果我们暴露一个这样的方法:

calculate(IAlgo* algo)。

用户必须提供一个继承自 IAlgo 的类。然而,如果我们使用泛型,把它定义为 calculate<T>,用户只需提供一个具备所需方法的类,而不必非得继承 IAlgo。而且,如果因为新增方法, IAlgo 变成了 IAlgo2,库的用户也不会受到影响。

理想情况下,库暴露的接口绝不能有不兼容的变更;当库内部引入变更时,用户绝不能受到影响。泛型方法最适合这种约束,因为在需要变更时它有极强的容忍度。

使用的外部 API

下面是 memcache++ 使用的外部类型:

memcache++ 主要依靠 Boost 和 STL 来实现其目标;下面是它用到的一些 Boost 特性:

  • 多线程。
  • 算法。
  • spirit(解析框架)。
  • asio(网络库)。
  • 单元测试。

在 STL 中,使用最多的是容器。

那么最后,使用泛型方法有哪些优势?

  • 衡量 memcache++ 设计选择效率的第一个指标是代码行数(LOC)——只有大约 600 行;这一成果主要归因于两个原因:
  • 泛型方法消除了样板代码。
  • 充分利用了 Boost 和 STL 的丰富功能。
  • 第二个优势是它的灵活性:任何变更都只影响极小一部分代码。
分享本文