C/C++符号隐藏与依赖管理(一):代码符号隐藏

C语言中全局变量和函数的符号是默认外部可访问的。

只要我们知道一个全局变量或者函数的声明,我们就可以在当前的编译单元中直接使用它,即使它定义在另一个编译单元中,甚至是定义在另一个软件库中。由于符号全局可访问,链接器会在链接期帮我们跨编译单元找到对应的符号并进行链接。

C语言这种默认的全局可访问性看起来使用简单,但却在实践中引起了很多麻烦。

首先,全局可访问性增加了代码符号的冲突几率。为了避免符号冲突,在大的C项目中我们必须为所有全局变量和函数起很长的名字,一般需要加上“子系统名”或者“模块名”之类的前缀。这样导致代码不够简洁,而且生成的二进制还会占用更多的空间。

其次,全局可访问性让使用extern的成本很低。extern为使用外部符号提供了一种直通车机制,这种做法绕过了别人提供的头文件,可以直接引用对方本不想不暴露的符号。这不仅造成一种间接的隐式依赖,而且还导致了潜在的安全风险。

extern不加控制的项目,其依赖关系最终肯定会变成一团乱麻。更进一步,extern会造成全局变量和函数原型的重复声明,这不仅破坏了DRY(Don't Repeat Yourself)原则,还为代码埋下了潜在的安全问题。

我已经不止一次在非常关注可靠性的项目中目睹过全局变量的维护者修改了变量类型,如将U32 g_ports[MAX_NUM]修改为U16 g_ports[MAX_NUM],但是不小心遗漏了某处extern U32 g_ports[MAX_NUM],然后引起了各种难以定位的内存和复位问题。

所以,我们需要遵守的第一条重要的原则是:尽量避免使用extern关键字

extern只在很少几种情况下是有用的,例如明确要链接某些第三方的没有头文件的二进制库,或者调用汇编编写的函数以及访问编译器/链接器自动生成的符号等。

尽力消灭代码中的extern绝对会改善你的设计,但是这并没有改变C语言会将符号置为全局可见的事实。这时我们需要另一个非常重要的关键字static来帮忙。

static是C语言中仅有的用于隐藏符号的手段,因此用好它的意义十分重要。

static在C语言中主要有两种作用。1)对于函数内的局部变量,它指示该变量的内存不在栈上,而在全局静态区。2)对于全局变量和函数来说,它指示对应的符号可见性被约束在本编译单元内,不会暴露出去。

对于符号隐藏,我们主要使用static的第二个用途。由于使用static修饰的全局变量和函数的符号不会被导出,所以我们可以给这些变量和函数起更精炼的名字,同时编译器也会帮我们做更好的优化,生成更小的二进制。

更重要的是,尽量多的使用static会让我们改善设计,进而得到符合Modular C风格的设计。

Modular C风格的设计最基本的就是将状态(全局变量)和无需暴露的函数通过static隐藏到编译单元内部,只将真正的API接口声明到头文件中。由于使用static修饰的符号是没法extern的,结合上一条建议,强制使用方只能显示的通过包含对应的头文件来调用开放的API,这样代码自然变得更加的模块化。

所以,我们给出C语言符号隐藏另一个原则:尽可能多的使用static关键字来封装细节,让代码遵从Modular C的设计风格

现在我们转向C++。得益于C++的面向对象特性,我们有了类以及对应的访问性控制关键字privateprotectedpublic

这些关键字可以修饰类的成员以及类的继承关系,从而对内和对外呈现出不同级别的可访问性。这些关键字的用法在各种教科书中都有,本文不做更多介绍。 推荐大家熟练掌握这些关键字的用法,记得千万不要把类中的一切都公开出去(虽然我见过很多人确实这么做的)。

记住一个原则,那就是尽可能多的使用private关键字

除了类,C++语言还有一个用于隐藏信息极好的特性,那就是命名空间namespacenamespace让我们能够对符号分类,将其控制在独立的命名空间中,而不用像C语言中那样靠增加名字前缀来避免符号冲突。

遗憾的是C++中命名空间是没有可访问性控制的,也就是说命名空间中的符号全部是公开的,外部通过命名空间路径都是可以访问到的。

不过C++语言提供了匿名命名空间的特性,凡是在匿名命名空间中的符号都是不导出的。也就是说匿名命名空间中的符号只在本编译单元内部可见,外部是不能使用的。其作用类似于C语言中的static,但是写起来更加简洁。

// example.cpp

namespace {
    struct Port {
        // ...
    };

    Port ports[MAX_NUM];

    unsigned int getRateOf(const Port& port) {
        // ...
    }
}

unsigned int getPortRate(unsigned int portId) {
    // ...
}

如上面例子中:PortportsgetRateOf只能在"example.cpp"中访问,而getPortRate则在该编译单元外也可以使用。

因此对于C++语言,我们推荐:尽可能使用命名空间来管理符号,尤其是使用匿名命名空间来隐藏符号

C++语言为了兼容C,仍旧使用头文件机制发布API。为了在C++的头文件中更好的隐藏符号,我们在这里先来区分两个概念:“可见性”与“可访问性”。

以下面这个Storage类定义的头文件“Storage.h”为例:

// Storage.h

#include "StorageType.h"

class Storage {
public:
    Storage();
    unsigned int getCharge() const;
private:
    bool isValid() const;
private:
    StorageType type;
    unsigned int capacity; 
    static unsigned int totalCapacity;
};

用户只要包含这个头文件,就可以看到Storage类中的所有的方法声明以及成员变量定义。因此从可见性上来说,这个类的所有函数声明和成员变量的定义都是外部可见的。然而从可访问性上来说,我们只能访问这个类的公开的构造函数Storage()getCharge()接口。

从上面的例子中可以看到,C++头文件中类定义对外的可见性和可访问性是不一致的。

当可见性大于可访问性的时候,带来的问题是:当我们修改了类的私有函数或者成员变量定义(用户可见但是不可访问的符号)时,事实上并不会影响用户对该类的使用方式,然而所有使用该类的用户却被迫要承担重新编译的负担。

为了避免上面的问题,降低客户重新编译的负担,我们需要在头文件中尽量少的暴露信息。对类来说需要尽量让其外部可见性和可访问性在头文件中趋于一致。

那要怎么做呢?主要有以下手段:

  • 可以将类的静态私有(static private)成员直接转移到类实现文件中的匿名命名空间中定义;

如上例中的static unsigned int totalCapacity是不需要定义到类的头文件中的,可以直接定义到该类实现文件的匿名命名空间中。

// Storage.cpp

#include "Storage.h"

namespace
{
    // remove "static unsigned int totalCapacity" in Storage.h, and define it here
    unsigned int totalCapacity = 0;
}

Storage::Storage() {
    // ...
}

bool Storage::isValid() const {
    if (this->capacity > totalCapacity) {
        // ...
    }
    // ...
}

unsigned Storage::int getCharge() const {
    if(this->isValid(this->capacity)) {
        // ...
    }
    // ...
}
  • 对于类的普通私有成员方法,可以将它依赖的成员变量当做参数传给它,这样它就可以变成类的静态私有函数。然后就可以依照前面的方法将其移到类实现文件中的匿名命名空间中;

如上例中类的bool isValid() const私有成员方法的实现中访问了类的成员变量this->capacity。我们修改isValid方法的实现,将capacity作为参数传递给它,这样isValid在类中的声明就可以变为static bool isValid(unsigned int capacity),实现变为:

// Storage.cpp

bool Storage::isValid(unsigned int capacity) {
    if (capacity > totalCapacity) {
        // ...
    }
    // ...
}

现在我们就已经可以参照前面的原则,将类的私有静态成员搬移到实现文件的匿名命名空间中,将其在头文件中的声明删除。

// Storage.h

#include "StorageType.h"

class Storage {
public:
    Storage();
    unsigned int getCharge() const;
private:
    StorageType type;
    unsigned int capacity; 
};
// Storage.cpp

#include "Storage.h"

namespace
{
    unsigned int totalCapacity = 0;

    bool isValid(unsigned int capacity) {
        if (capacity > totalCapacity) {
            // ...
        }
        // ...
    }    
}

Storage::Storage() {
    // ...
}

unsigned Storage::int getCharge() const {
    if(isValid(this->capacity)) {
        // ...
    }
    // ...
}

经过上面的操作,类中的私有方法和静态私有成员都从头文件移到了实现文件的匿名命名空间中了。那么最后剩下的类的非静态私有成员变量能否也隐藏起来呢?

方法是有的,就是使用PIMPL(pointer to implementation)方法。

  • 可以使用PIMPL方法隐藏类的私有成员。

对于上例,使用PIMPL后实现如下:

// storage.h

class Storage {
public:
    Storage();
    unsigned int getCharge() const;
    ~Storage();
private:
    class Impl;
    Impl* p_impl{nullptr}; 
};
// Storage.cpp

#include "Storage.h"
#include "StorageType.h"

namespace
{
    unsigned int totalCapacity = 0;

    bool isValid(unsigned int capacity) {
        if (capacity > 0) {
            // ...
        }
        // ...
    } 
}

class Storage::Impl {
public:
    Impl() {
        // original implmentation of Storage::Storage()
    }

    unsigned int getCharge() const {
        // original implmentation of Storage::getCharge()
    }
private:
    StorageType type;
    unsigned int capacity;     
};

Storage::Storage() : p_impl(new Impl()){
}

Storage::~Storage(){
    if(p_impl) delete p_impl;
}

unsigned int Storage::getCharge() const {
    return p_impl->getCharge();
}

可以看到,使用PIMPL方法就是把所有的调用都委托到一个内部类(本例中的Impl)的指针上。

由于指针的类型只用做前置声明,所以使用PIMPL手法的类的私有成员只用包含一个内部类的前置声明和一个成员指针即可。而Impl类则包含了原来类的所有真正的成员和函数实现。因为Impl类可以实现在cpp文件中,所以达到了进一步隐藏信息的效果。

从上例我们看到,由于Storage类的所有私有成员都转移到了内部的Impl类中,所以Storage类的头文件中不再需要包含"StorageType.h",只用在实现文件中包含即可。因此使用PIMPL手法,可以解决头文件耦合与物理依赖传递的问题。

不过,通过代码示例也可以看到使用PIMPL方法是有成本的,它增加了间接函数调用和动态内存分配的开销。而且由于代码多了一层封装,导致整体复杂度上升了。因此除非解决某些严重的物理依赖问题,一般不会大面积使用该手法。

最后,一个完备的PIMPL实现会借助unique_ptr类型的智能指针。本例为了简化示例所以采用了裸指针实现,更完整和通用的PIMPL实现可以参见 https://en.cppreference.com/w/cpp/language/pimpl

到此,我们总结一下C/C++语言自身有关符号可见性控制的原则和方法:

1) 尽量避免使用extern关键字;
2) 对于C语言,尽可能多的使用static关键字来封装细节,让代码遵从Modular C的设计风格;
3)对于C++,尽可能多的使用private关键字;
4)对于C++,尽可能使用命名空间来管理符号,尤其是使用匿名命名空间来隐藏符号;
5)头文件尽量隐藏信息,缩小头文件内的符号可见性。可以采取的手段有:
    - 将类的静态私有成员转移到实现文件的匿名命名空间中;
    - 在某些情况下,可以将类的私有方法重构成类的静态私有方法,然后移入到实现文件的匿名命名空间中;
    - 对于某些严重的头文件耦合问题,可以选择使用PIMPL方法,隐藏类的所有非公开成员及其依赖的头文件;

C/C++符号隐藏与依赖管理(二):库的符号隐藏

推荐阅读更多精彩内容