查看原文
其他

从入门到精通:如何解决C++模板代码膨胀问题?

guoling 微信客户端技术团队 2024-04-20

作者:guoling,来自微信客户端团队

前言

  • 背景:C++ 模板是一种强大的编程工具,它允许我们编写通用的、可重用的代码;
  • 问题:模板代码的一个常见问题是代码膨胀,即编译器为每个模板实例生成大量的重复代码。现代的编译器已经能够对不同编译单元里的相同模板函数进行去重,老生常谈的 external 模板、将模板代码与非模板代码分离等,对瘦身意义已经不大,我们仍然需要关注如何减少每一个模板实例化的大小。

除了显而易见的减少实例化类型的数量(实际业务场景下其实大部分减不了),「本文主要是提供适用于一些具体场景、可实际操作的优化策略以减少C++模板代码的大小。」

策略说明

主要包括:

  1. 模板函数:提取通用部分
  2. 模板类:抽象出通用部分到基类
  3. 合理使用模板
  4. 小技巧:多用组合、避免使用大型对象等等。

1. 将模板函数的通用部分提取出来

如果模板函数中有一部分代码与模板参数无关,那么可以将这部分代码提取出来,放到一个非模板函数中。这样,这部分代码只需要生成一次,而不是在每个模板实例中都生成一次。
为了方便讨论,后续的例子基于这个场景:我们提供一个集中的 Service 单例管理器。以下是大体的框架:

// 所有 Service 需要实现的接口
class BaseService {
public:
    virtual ~BaseService() = default;
    virtual void onServiceInit() 0;
    ……    
    std::string contextName{};
};
// 所有 Service 单例的统一管理中心
class ServiceCenter {
public:
    explicit ServiceCenter(const std::string& name) : _contextName(name) {}

    template<typename T>
    std::shared_ptr<T> getService() 
{
        ……
    }
    
private:
    std::unordered_map<std::stringstd::shared_ptr<BaseService>> _serviceMap = {};
    std::string _contextName;
    std::recursive_mutex _mutex;
};

1.1 最简单的情形,函数大部分逻辑都是跟模板参数无关:

例如,在我们的例子中,getService() 函数最简单的版本可能长这样,显然,一大部分代码是与模板参数无关的,可以提取出来:

class ServiceCenter {
public:
    template<typename T>
    std::shared_ptr<T> getService() 
{
        auto const key = typeid(T).name();
        std::lock_guard<std::recursive_mutex> lock(_mutex);
        auto const itr = _serviceMap.find(key);
        if (itr == _serviceMap.end()) {
            return nullptr;
        }
        auto service = itr->second;
        return std::dynamic_pointer_cast<T>(service);
    }
};

我们抽出一个非模板的函数getService(const std::string &key),将加锁、查询 map 这些逻辑都挪进去,优化后:

class ServiceCenter {
public:
    std::shared_ptr<BaseService> getService(const std::string &key) {
        std::lock_guard<std::recursive_mutex> lock(_mutex);
        auto const itr = _serviceMap.find(key);
        if (itr == _serviceMap.end()) {
            return nullptr;
        }
        return itr->second;
    }

    template<typename T>
    std::shared_ptr<T> getService() 
{
        auto const key = typeid(T).name();
        auto service = getService(key);
        return std::dynamic_pointer_cast<T>(service);
    }
};

1.2 稍复杂的情形,函数大部分逻辑都跟模板参数有关:

例如,getService()函数不但要管查询,还要按需创建新实例、初始化、以及各种异常处理,有3行代码都用了类型T:

class ServiceCenter {
public:
    template<typename T>
    std::shared_ptr<T> getService() 
{
        std::lock_guard<std::recursive_mutex> lock(_mutex);
    
        auto const key = typeid(T).name();
        auto const service = getService(key);
        if (service == nullptr) {
            auto const tService = std::make_shared<T>();
            tService->contextName = _contextName;
            setService(key, tService);
            tService->onServiceInit();
            return tService;
        } else {
            auto const tService = std::dynamic_pointer_cast<T>(service);
            if (tService == nullptr) {
                aerror("ServiceCenter""tService is null");
                return nullptr;
            }
            return tService;
        }
    }

    void setService(const std::string &key, const std::shared_ptr<AffBaseService> &service) {....}
};

这种情况我们可以将需要用到类型T的地方进行封装,抽象出一套协议。具体来说,用到T的地方分别有:

  • typeid(T).name(): 可抽象出接口getTypeName(),通过 RTTI 返回类型的名字。
  • std::make_shared(): 考虑到对tService接下来的操作都是已在基类BaseService里定义的接口,可抽象出接口newInstance(),返回基类指针。
  • std::dynamic_pointer_cast(): 这里主要是将基类指针动态地转换为子类指针,可抽象出接口castToOriginType(),在里面进行类型转换,返回一个void类型的指针。

使用最常见的多态(也可用 C 函数指针数组std::function<>传参等)来组织这 3 个抽象接口:

class ServiceTypeHelperBase {
public:
    virtual ~ServiceTypeHelperBase() = default;
    virtual const char* getTypeName() const 0;
    virtual std::shared_ptr<BaseService> newInstance() const 0;
    virtual std::shared_ptr<void> castToOriginType(std::shared_ptr<BaseService> service) const 0;
};
    
template<typename T>
class ServiceTypeHelper : public ServiceTypeHelperBase {
    const char* getTypeName() const override {
        return typeid(T).name();
    }
    std::shared_ptr<BaseService> newInstance() const override {
        return std::make_shared<T>();
    }
    std::shared_ptr<void> castToOriginType(std::shared_ptr<BaseService> service) const override {
        return std::dynamic_pointer_cast<T>(service);
    }
};

然后就可以抽出非模板的函数getService(const ServiceTypeHelperBase* helper)。可见到,得益于合理的抽象,新函数跟没优化之前的getService()几乎一模一样:

class ServiceCenter {
public:
    std::shared_ptr<void> getService(const ServiceTypeHelperBase* helper) {
        std::lock_guard<std::recursive_mutex> lock(_mutex);

        auto const key = helper->getTypeName();
        auto const service = getService(key);
        if (service == nullptr) {
            auto const tService = helper->newInstance();
            tService->contextName = _contextName;
            setService(key, tService);
            tService->onServiceInit();
            return helper->castToOriginType(tService);
        } else {
            auto const tService = helper->castToOriginType(service);
            if (tService == nullptr) {
                aerror("ServiceCenter""tService is null");
                return nullptr;
            }
            return tService;
        }
    }

    template<typename T>
    std::shared_ptr<T> getService() 
{
        ServiceTypeHelper<T> helper;
        auto service = getService(&helper);
        return std::static_pointer_cast<T>(service);
    }
};

注意,抽象出来的接口必须「足够精简,避免换个地方写模板函数」;太复杂的话,起不到瘦身效果。例如下面这样,就是不够精简的抽象:

// 反面例子,不要这样做
template<typename T>
class ServiceTypeHelper : public ServiceTypeHelperBase {
    const char* getTypeName() const override {
        return typeid(T).name();
    }
   std::shared_ptr<BaseService> newInstance() const override {
        auto const tService = std::make_shared<T>();
        tService->contextName = _contextName;
        setService(key, tService);
        tService->onServiceInit();
        return tService;
    }
   std::shared_ptr<void> castToOriginType(std::shared_ptr<BaseService> service) const override {
        auto const tService = std::dynamic_pointer_cast<T>(service);
        if (tService == nullptr) {
            aerror("ServiceCenter""tService is null");
            return nullptr;
        }
        return tService;
    }
};

2. 将模板类的通用部分提取到基类

特别注意:这里的基类指「非模板基类」,或者「模板参数比子类少的基类」;否则只是换个地方写模板类,起不到瘦身效果。

编译器每实例化一个模板类,会将类的所有部分都复制一份,包括非模板成员变量、模板成员变量、非模板函数、模板函数。尤其是「非模板成员变量和非模板函数,也会复制生成一份」,即使它们没有用到模板信息。这是很多人都会忽视的地方。因此,将通用部分提取到基类,避免编译器重复生成同样的代码,就成了瘦身的有效手段。
为了方便讨论,依然以 ServiceCenter 来举例。假设我们每个大业务(朋友圈、视频号等)都有自己的一套 XXXBaseService,定制了一些大业务相关的通用处理逻辑,因此他们希望有业务专门的 XXXServiceCenter,大概是这么一个架构:

// 业务相关的 BaseService 协议
class BussinessBaseService : public BaseService {
public:
    BussinessBaseService(const std::string& name) : _bussinessName(name) {}
    virtual void onBussinessEnter() 0;
    virtual void onBussinessExit() 0;
    ……
protected:
    const std::string _bussinessName;
};
// 业务相关的 ServiceCenter
template <typename BaseService_t>
class ServiceCenter {
public:
    explicit ServiceCenter();
    
public:
    void setService(const std::string &key, const std::shared_ptr<BaseService> &service);
    std::shared_ptr<BaseService> getService(const std::string &key);
    
    template<typename T>
    std::shared_ptr<T> getService() 
{
        static_assert(std::is_base_of<BaseService_t, T>::value, "Wrong Service Type");
        ……
    }
……
private:
    std::unordered_map<std::stringstd::shared_ptr<BaseService>> _serviceMap = {};
    std::string _bussinessName;
    std::string _contextName;
    std::recursive_mutex _mutex;
};
// 例如朋友圈业务 Service 基类
class TLBussinessServiceBase : public BussinessBaseService {
    TLBussinessServiceBase(const std::string& name) : BussinessBaseService(name) {}
    void onBussinessEnter() override /*some common logic*/ }
    void onBussinessExit() override /*some common logic*/ }
    ……
};
// 朋友圈 ServiceCenter,要求所有 Service 都继承自 TLBussinessServiceBase
static ServiceCenter<TLBussinessServiceBase> g_tlServiceCenter;

2.1 将非模板成员变量和非模板函数提取到基类

这个是不言自明的机械操作:

class BaseServiceCenter {
public:
    void setService(const std::string &key, const std::shared_ptr<BaseService> &service);
    std::shared_ptr<BaseService> getService(const std::string &key);
    
protected:
    std::unordered_map<std::stringstd::shared_ptr<BaseService>> _serviceMap = {};
    std::string _contextName;
    std::recursive_mutex _mutex;
};
    
template <typename BaseService_t>
class ServiceCenter : BaseServiceCenter {
public:
    template<typename T>
    std::shared_ptr<T> getService() 
{
        ……
    }
……
private:
    std::string _bussinessName;
};

2.2 将模板函数抽出通用部分,挪到基类

我们先来看新版getService(),跟之前的有点不一样,Service 构造函数多一个参数:

template <typename BaseService_t>
class ServiceCenter : BaseServiceCenter {
public:
    template<typename T>
    std::shared_ptr<T> getService() 
{
        static_assert(std::is_base_of<BaseService_t, T>::value, "Wrong Service Type");
        std::lock_guard<std::recursive_mutex> lock(_mutex);
    
        auto const name = typeid(T).name();
        auto const service = getService(name);
        if (service == nullptr) {
            auto const tService = std::make_shared<T>(_bussinessName);  // 这里不一样
            tService->contextName = _contextName;
            setService(name, tService);
            tService->onServiceInit();
            return tService;
        } else {
            auto const tService = std::dynamic_pointer_cast<T>(service);
            if (tService == nullptr) {
                aerror("ServiceCenter""tService is null");
                return nullptr;
            }
            return tService;
        }
    }
};

因此我们抽象出来的 XXXHelper::newInstance() 也需要多一个参数:

class BussinessServiceTypeHelperBase : public ServiceTypeHelperBase {
public:
   std::shared_ptr<BaseService> newInstance() const override {
        return nullptr;
    }
    virtual std::shared_ptr<BussinessBaseService> newInstance(const std::string& name) const 0;
};
template<typename T>
class BussinessServiceTypeHelper : public BussinessServiceTypeHelperBase {
    const char* getTypeName() const override {
        return typeid(T).name();
    }
    std::shared_ptr<BussinessBaseService> newInstance(const std::string& name) const override {
        return std::make_shared<T>(name);
    }
    std::shared_ptr<void> castToOriginType(std::shared_ptr<BaseService> service) const override {
        return std::dynamic_pointer_cast<T>(service);
    }
};

然后就可以类似 1.2 那样抽出共有逻辑,并挪到基类:

class BaseServiceCenter {
public:
    std::shared_ptr<void> getService(const BussinessServiceTypeHelperBase* helper, const std::string& bussinessName) {
        std::lock_guard<std::recursive_mutex> lock(_mutex);

        auto const key = helper->getTypeName();
        auto const service = getService(key);
        if (service == nullptr) {
            auto const tService = helper->newInstance(bussinessName);
            tService->contextName = _contextName;
            setService(key, tService);
            tService->onServiceInit();
            return helper->castToOriginType(tService);
        } else {
            auto const tService = helper->castToOriginType(service);
            if (tService == nullptr) {
                aerror("ServiceCenter""tService is null");
                return nullptr;
            }
            return tService;
        }
    }
};

template <typename BaseService_t>
class ServiceCenter : BaseServiceCenter {
public:
    template<typename T>
    std::shared_ptr<T> getService() 
{
        static_assert(std::is_base_of<BaseService_t, T>::value, "Wrong Service Type");
        BussinessServiceTypeHelper<T> helper;
        auto service = getService(&helper, _bussinessName);
        return std::static_pointer_cast<T>(service);
    }
};

2.3 抽离(多模板参数)子类的共用部分,挪到(少模板参数的)基类

如果基类也有模板参数,那么应尽量使基类的模板参数比子类少,并把子类的共用部分挪到基类。例如,假设现在有如下子类和基类,T 的实例个数是 n,U 的实例个数是 m,那么子类的每个成员变量和成员函数都会「生成 n*m 份」;如果把子类里只与 T 相关的成员挪到基类,那么这些成员「只会生成 n 份」,少了一个数量级。更详细的分析可参考 Effective C++ 44:将参数无关代码重构到模板外去。

template<typename T, typename U>
class Pair {
public:
   std::string getFirstTypeName() const {
        auto typeName = typeid(T).name();
        int status;
        auto demangledName = abi::__cxa_demangle(typeName, 00, &status);
        if (!demangledName) {
            return typeName;
        }
        std::string result = demangledName;
        free(demangledName);
        return result;
    }
    
    T first;
    U second;
};

上面的例子里,getFirstTypeName()明显跟参数U无关,因此可抽出一个基类,并把该函数挪过去:

template<typename T>
class PairLeft {
public:
   std::string getTypeName() const {
        auto typeName = typeid(T).name();
        int status;
        auto demangledName = abi::__cxa_demangle(typeName, 00, &status);
        if (!demangledName) {
            return typeName;
        }
        std::string result = demangledName;
        free(demangledName);
        return result;
    }
};

template<typename T, typename U>
class Pair : private PairLeft<T> {
    using Base = PairLeft<T>;
public:
   std::string getFirstTypeName() const {
        return Base::getTypeName();
   }
};

还可以进一步将函数 PairLeft::getTypeName() 抽离出模板无关的部分,放到一个非模板基类里。具体可参考前文,不再赘述。

3. 合理使用模板

不要为了用模板而用模板。这里举一个具体的反面例子(github上某开源库):一个提供了类似上述 ServiceCenter 功能的库,它侧重点是控制反转 Inversion of Control,亮点是对构造函数依赖的参数的自动查找和构建,以及类型映射。我们学习一个库,除了学它好的地方,也要看到它不好的地方,引以为鉴。

3.1 多余的模板参数

场景1:基类 RegistrationDescriptorBase<TDescriptor, TDescriptorInfo>有两个模板参数,仔细看代码,会发现压根没在基类用过。而这个会导致非常严重的代码膨胀,每个<TDescriptor, TDescriptorInfo>组合就会生成一套全新的基类。

场景2:工具类 EnforceBaseOf<TDescriptorInfo, TBase, T>里面的TDescriptorInfo参数在判断T是否继承自TBase时,完全没用,不知为何要加这么一个参数。

3.2 臃肿的模板组合

这个形容词我想了很久,没找一个合适的词去形容,因为实在太震撼。具体是这个类AutowireableConstructorRegistrationDescriptor:

template <class TDescriptorInfo>
class AutowireableConstructorRegistrationDescriptor :
 public RegistrationDescriptorBase< AutowireableConstructorRegistrationDescriptor< TDescriptorInfo >, TDescriptorInfo >,
                                                      public RegistrationDescriptorOperations::As< AutowireableConstructorRegistrationDescriptor< TDescriptorInfo >, TDescriptorInfo >,
                                                      public RegistrationDescriptorOperations::OnActivated< AutowireableConstructorRegistrationDescriptor< TDescriptorInfo >, TDescriptorInfo >,
                                                      public RegistrationDescriptorOperations::SingleInstance< AutowireableConstructorRegistrationDescriptor< TDescriptorInfo >, TDescriptorInfo >,
                                                      ……
{
    ……
};

以及这个类RegistrationDescriptorInfo:

template
<
    class T,
    InstanceLifetimes:
:InstanceLifetime Lifetime = InstanceLifetimes::Transient,
    class TSelfRegistrationTag = Tags::NotSelfRegistered,
    class TFallbackRegistrationTag = Tags::DefaultRegistration,
    class TRegisteredBases = MetaMap<>,
    class TDependencies = MetaMap<>
>
struct RegistrationDescriptorInfo
{
    typedef T InstanceType;
    typedef std::integral_constant< InstanceLifetimes::InstanceLifetime, Lifetime > InstanceLifetime;
    typedef TSelfRegistrationTag SelfRegistrationTag;
    typedef TFallbackRegistrationTag FallbackRegistrationTag;
    typedef TRegisteredBases RegisteredBases;
    typedef TDependencies Dependencies;

    struct SingleInstance
    {

        typedef RegistrationDescriptorInfo
        <
            InstanceType,
            InstanceLifetimes::Persistent,
            SelfRegistrationTag,
            FallbackRegistrationTag,
            RegisteredBases,
            Dependencies
        >
        Type;
    };
    template <class TBase>

    struct RegisterBase
    {

        typedef RegistrationDescriptorInfo
        <
            InstanceType,
            InstanceLifetime::value,
            SelfRegistrationTag,
            FallbackRegistrationTag,
            typename MetaInsert< RegisteredBases, MetaPair< TBase, MetaIdentity< TBase > > >::Type,
            Dependencies
        >
        Type;
    };
    template <class TBase>
    struct IsBaseRegistered :
 MetaContains< RegisteredBases, TBase >
    {
    };
    ……
};

这两个模板类组合起来,可以提供类似下面这样的语法:

auto contain_builder = Hypodermic::ContainerBuilder();
contain_builder.registerType<TServiceImp>()
    .template as<TServiceInterface>()
    .template as<TServiceBase>()
    .singleInstance()
    .onActivated(
        [](Hypodermic::ComponentContext &ctx, const std::shared_ptr<TServiceImp> &service) {
            // some onActive logic
        }
    );

看起来挺干爽的,有什么问题?

  • 所有「调用信息和调用顺序」,都通过模板参数在RegistrationDescriptorInfo记录下来,这就意味着每多一步操作,就多了一个模板组合。就拿上面这个链式调用来说,一共有 5 个函数调用,也就生成了若干套RegistrationDescriptorInfo、AutowireableConstructorRegistrationDescriptor。调用量越多,生成的类型越多,二进制大小线性增长。
  • 基类用子类作为模板参数 RegistrationDescriptorBase< AutowireableConstructorRegistrationDescriptor< TDescriptorInfo >, TDescriptorInfo >。结合前面 4.1 说的,基类压根就没用上这两个模板参数,进一步加剧了生成类型的数量。
  • 为了使得新增的调用信息对 contain_builder可见,contain_builder需要注册registrationDescriptorUpdated()通知,在回调里处理新的调用信息。代码架构非常复杂混乱。

问题如此严重,那要怎么优化?回头看作者的用心,大概或许应该是防止用户出错。例如映射的基类并不是基类、重复映射同一个基类、重复设置singleInstance,等等「可以在编译期发现的错误」。如果抛开这个大设定,其实所有配置信息都可以用一个 POD (Plain Old Data) 结构体来记录,最后再一次性生效。在 POD 结构体的基础上,我们再来看哪些是可以零成本在编译期完成的错误检查:

  • 「映射的基类不是基类」:这个可以零成本在编译器实现,加一个static_assert<>即可。
  • 「重复映射同一个基类」:这个其实可以在运行时规避/处理,我们用一个unordered_set来记录已映射的基类即可。退一万步说,把“映射同一个基类”做成幂等操作就行了,重复映射一万次都没关系。
  • 「重复设置singleInstance」:同上,用一个 bool 标志位记录即可,以最后一次调用为准。

其他接口如named(), asSelf(), with()等等,也都可参考上面做法,分别在编译期/运行时解决,不再赘述。

4. 小技巧

4.1 多用组合,少用继承

通常来说,多用组合少用继承是设计模式上的好建议,它能提高灵活性、减低耦合度、增强代码复用、减少继承层次。但其实它还有瘦身意义上的好处。假设我们有一个模板类 GraphicObject,它有两个模板参数:Shape 表示形状类型,Color 表示颜色类型。

template<typename Shape, typename Color>
class GraphicObject {
public:
    // ...
};

如果我们有很多不同的 Shape 和 Color 类型,那么 GraphicObject 的每种组合都会生成一个新的模板实例,这可能会导致生成的代码量非常大。

为了减少模板实例化的大小,我们可以将 Shape 和 Color 类型的处理逻辑分离出来,使它们成为 GraphicObject 的成员,而不是模板参数。这样,GraphicObject 就不再需要为每种 Shape 和 Color 类型的组合生成一个新的模板实例,从而减少了模板实例化的大小。

class ShapeBase {
public:
    // ...
};

class ColorBase {
public:
    // ...
};

class GraphicObject {
public:
    GraphicObject(std::shared_ptr<ShapeBase> shape, std::shared_ptr<ColorBase> color)
        : shape_(shape), color_(color) {}
    // ...
private:
    std::shared_ptr<ShapeBase> shape_;
    std::shared_ptr<ColorBase> color_;
};

4.2 避免在模板函数中使用大型对象

模板函数中的对象会在每个模板实例中都生成一份,因此应该避免在模板函数中使用大型对象。如果必须使用大型对象,可以考虑使用指针或引用,或者将对象移动到函数外部。

4.3 善用手边工具,多测量监控代码膨胀问题

Linux 平台下(包括Android),可用 nm 打印出每个符号的大小并按大小排序:

nm --print-size --size-sort xxx_binary

在 macOS/iOS 平台下,可通过生成 LinkMapFile.txt 来进行分析。

需要注意的是,现代编译器能够对不同编译单元的相同模板函数进行去重,所以需要对最终链接产物(可执行文件 / 动态库)进行测量,不要对单个 .o .a 文件进行分析。

优化效果

上述描述的策略目前正逐步应用到微信客户端内进行优化,目前的优化效果是:「将有24个 Service 的代码库从14M瘦身到11M,减少体积22%,效果非常明显。」

总结

总的来说,优化C++模板代码的关键是减少每个模板实例的大小,本文描述的优化策略可以帮助我们提高编译速度,减小生成的二进制文件大小,同时保持代码的可读性和可维护性,完整总结如下:

想了解更多「微信客户端技术及开发经验」,请关注「微信客户端技术团队公众号」


继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存