C++模板代码膨胀问题:从基础到高级解决方案

发表时间: 2024-03-12 14:49

作者: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::string, std::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;};// 业务相关的 ServiceCentertemplate <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::string, std::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 都继承自 TLBussinessServiceBasestatic 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::string, std::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, 0, 0, &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, 0, 0, &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++模板代码的关键是减少每个模板实例的大小,本文描述的优化策略可以帮助我们提高编译速度,减小生成的二进制文件大小,同时保持代码的可读性和可维护性,完整总结如下:

作者:guoling

来源:微信公众号:微信客户端技术团队

出处
:https://mp.weixin.qq.com/s/aRqKGoVNcf8yzRhIHbmoBA