C語言中全局變量和函數(shù)的符號是默認(rèn)外部可訪問的几蜻。
只要我們知道一個全局變量或者函數(shù)的聲明,我們就可以在當(dāng)前的編譯單元中直接使用它体斩,即使它定義在另一個編譯單元中梭稚,甚至是定義在另一個軟件庫中。由于符號全局可訪問絮吵,鏈接器會在鏈接期幫我們跨編譯單元找到對應(yīng)的符號并進(jìn)行鏈接弧烤。
C語言這種默認(rèn)的全局可訪問性看起來使用簡單,但卻在實踐中引起了很多麻煩蹬敲。
首先暇昂,全局可訪問性增加了代碼符號的沖突幾率。為了避免符號沖突伴嗡,在大的C項目中我們必須為所有全局變量和函數(shù)起很長的名字急波,一般需要加上“子系統(tǒng)名”或者“模塊名”之類的前綴。這樣導(dǎo)致代碼不夠簡潔闹究,而且生成的二進(jìn)制還會占用更多的空間幔崖。
其次,全局可訪問性讓使用extern
的成本很低。extern
為使用外部符號提供了一種直通車機(jī)制赏寇,這種做法繞過了別人提供的頭文件吉嫩,可以直接引用對方本不想不暴露的符號。這不僅造成一種間接的隱式依賴嗅定,而且還導(dǎo)致了潛在的安全風(fēng)險自娩。
對extern
不加控制的項目,其依賴關(guān)系最終肯定會變成一團(tuán)亂麻渠退。更進(jìn)一步忙迁,extern
會造成全局變量和函數(shù)原型的重復(fù)聲明碎乃,這不僅破壞了DRY(Don't Repeat Yourself)
原則梅誓,還為代碼埋下了潛在的安全問題梗掰。
我已經(jīng)不止一次在非常關(guān)注可靠性的項目中目睹過全局變量的維護(hù)者修改了變量類型及穗,如將U32 g_ports[MAX_NUM]
修改為U16 g_ports[MAX_NUM]
埂陆,但是不小心遺漏了某處extern U32 g_ports[MAX_NUM]
猜惋,然后引起了各種難以定位的內(nèi)存和復(fù)位問題著摔。
所以谍咆,我們需要遵守的第一條重要的原則是:盡量避免使用extern
關(guān)鍵字私股。
extern
只在很少幾種情況下是有用的倡鲸,例如明確要鏈接某些第三方的沒有頭文件的二進(jìn)制庫,或者調(diào)用匯編編寫的函數(shù)以及訪問編譯器/鏈接器自動生成的符號等逼争。
盡力消滅代碼中的extern
絕對會改善你的設(shè)計誓焦,但是這并沒有改變C語言會將符號置為全局可見的事實杂伟。這時我們需要另一個非常重要的關(guān)鍵字static
來幫忙赫粥。
static
是C語言中僅有的用于隱藏符號的手段傅是,因此用好它的意義十分重要喧笔。
static
在C語言中主要有兩種作用。1)對于函數(shù)內(nèi)的局部變量浆劲,它指示該變量的內(nèi)存不在棧上牌借,而在全局靜態(tài)區(qū)膨报。2)對于全局變量和函數(shù)來說现柠,它指示對應(yīng)的符號可見性被約束在本編譯單元內(nèi)弛矛,不會暴露出去丈氓。
對于符號隱藏,我們主要使用static
的第二個用途饮怯。由于使用static
修飾的全局變量和函數(shù)的符號不會被導(dǎo)出硕淑,所以我們可以給這些變量和函數(shù)起更精煉的名字置媳,同時編譯器也會幫我們做更好的優(yōu)化拇囊,生成更小的二進(jìn)制寥袭。
更重要的是传黄,盡量多的使用static
會讓我們改善設(shè)計,進(jìn)而得到符合Modular C風(fēng)格的設(shè)計识埋。
Modular C風(fēng)格的設(shè)計最基本的就是將狀態(tài)(全局變量)和無需暴露的函數(shù)通過static
隱藏到編譯單元內(nèi)部窒舟,只將真正的API接口聲明到頭文件中惠豺。由于使用static
修飾的符號是沒法extern
的耕腾,結(jié)合上一條建議,強制使用方只能顯示的通過包含對應(yīng)的頭文件來調(diào)用開放的API狼纬,這樣代碼自然變得更加的模塊化疗琉。
所以盈简,我們給出C語言符號隱藏另一個原則:盡可能多的使用static
關(guān)鍵字來封裝細(xì)節(jié)柠贤,讓代碼遵從Modular C的設(shè)計風(fēng)格臼勉。
現(xiàn)在我們轉(zhuǎn)向C++。得益于C++的面向?qū)ο筇匦云靶唬覀冇辛祟愐约皩?yīng)的訪問性控制關(guān)鍵字private
氓扛、protected
和public
幢尚。
這些關(guān)鍵字可以修飾類的成員以及類的繼承關(guān)系,從而對內(nèi)和對外呈現(xiàn)出不同級別的可訪問性理茎。這些關(guān)鍵字的用法在各種教科書中都有,本文不做更多介紹础倍。 推薦大家熟練掌握這些關(guān)鍵字的用法忆家,記得千萬不要把類中的一切都公開出去(雖然我見過很多人確實這么做的)。
記住一個原則卸例,那就是盡可能多的使用private
關(guān)鍵字。
除了類旦装,C++語言還有一個用于隱藏信息極好的特性,那就是命名空間namespace
呻袭。namespace
讓我們能夠?qū)Ψ柗诸悾瑢⑵淇刂圃讵毩⒌拿臻g中,而不用像C語言中那樣靠增加名字前綴來避免符號沖突栈拖。
遺憾的是C++中命名空間是沒有可訪問性控制的,也就是說命名空間中的符號全部是公開的,外部通過命名空間路徑都是可以訪問到的器仗。
不過C++語言提供了匿名命名空間的特性暴心,凡是在匿名命名空間中的符號都是不導(dǎo)出的弹沽。也就是說匿名命名空間中的符號只在本編譯單元內(nèi)部可見,外部是不能使用的丽已。其作用類似于C語言中的static
,但是寫起來更加簡潔嘁灯。
// example.cpp
namespace {
struct Port {
// ...
};
Port ports[MAX_NUM];
unsigned int getRateOf(const Port& port) {
// ...
}
}
unsigned int getPortRate(unsigned int portId) {
// ...
}
如上面例子中:Port
羹奉、ports
和getRateOf
只能在"example.cpp"中訪問病蛉,而getPortRate
則在該編譯單元外也可以使用俗孝。
因此對于C++語言革骨,我們推薦:盡可能使用命名空間來管理符號农尖,尤其是使用匿名命名空間來隱藏符號。
C++語言為了兼容C良哲,仍舊使用頭文件機(jī)制發(fā)布API盛卡。為了在C++的頭文件中更好的隱藏符號,我們在這里先來區(qū)分兩個概念:“可見性”與“可訪問性”筑凫。
以下面這個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
類中的所有的方法聲明以及成員變量定義。因此從可見性上來說巍实,這個類的所有函數(shù)聲明和成員變量的定義都是外部可見的滓技。然而從可訪問性上來說,我們只能訪問這個類的公開的構(gòu)造函數(shù)Storage()
和getCharge()
接口棚潦。
從上面的例子中可以看到令漂,C++頭文件中類定義對外的可見性和可訪問性是不一致的。
當(dāng)可見性大于可訪問性的時候丸边,帶來的問題是:當(dāng)我們修改了類的私有函數(shù)或者成員變量定義(用戶可見但是不可訪問的符號)時叠必,事實上并不會影響用戶對該類的使用方式,然而所有使用該類的用戶卻被迫要承擔(dān)重新編譯的負(fù)擔(dān)原环。
為了避免上面的問題挠唆,降低客戶重新編譯的負(fù)擔(dān),我們需要在頭文件中盡量少的暴露信息嘱吗。對類來說需要盡量讓其外部可見性和可訪問性在頭文件中趨于一致玄组。
那要怎么做呢?主要有以下手段:
- 可以將類的靜態(tài)私有(static private)成員直接轉(zhuǎn)移到類實現(xiàn)文件中的匿名命名空間中定義谒麦;
如上例中的static unsigned int totalCapacity
是不需要定義到類的頭文件中的俄讹,可以直接定義到該類實現(xiàn)文件的匿名命名空間中。
// 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)) {
// ...
}
// ...
}
- 對于類的普通私有成員方法绕德,可以將它依賴的成員變量當(dāng)做參數(shù)傳給它患膛,這樣它就可以變成類的靜態(tài)私有函數(shù)。然后就可以依照前面的方法將其移到類實現(xiàn)文件中的匿名命名空間中耻蛇;
如上例中類的bool isValid() const
私有成員方法的實現(xiàn)中訪問了類的成員變量this->capacity
踪蹬。我們修改isValid
方法的實現(xiàn),將capacity
作為參數(shù)傳遞給它臣咖,這樣isValid
在類中的聲明就可以變?yōu)?code>static bool isValid(unsigned int capacity)跃捣,實現(xiàn)變?yōu)椋?/p>
// Storage.cpp
bool Storage::isValid(unsigned int capacity) {
if (capacity > totalCapacity) {
// ...
}
// ...
}
現(xiàn)在我們就已經(jīng)可以參照前面的原則,將類的私有靜態(tài)成員搬移到實現(xiàn)文件的匿名命名空間中夺蛇,將其在頭文件中的聲明刪除疚漆。
// 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)) {
// ...
}
// ...
}
經(jīng)過上面的操作,類中的私有方法和靜態(tài)私有成員都從頭文件移到了實現(xiàn)文件的匿名命名空間中了。那么最后剩下的類的非靜態(tài)私有成員變量能否也隱藏起來呢娶聘?
方法是有的闻镶,就是使用PIMPL(pointer to implementation)方法。
- 可以使用PIMPL方法隱藏類的私有成員丸升。
對于上例铆农,使用PIMPL后實現(xiàn)如下:
// 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方法就是把所有的調(diào)用都委托到一個內(nèi)部類(本例中的Impl
)的指針上发钝。
由于指針的類型只用做前置聲明顿涣,所以使用PIMPL手法的類的私有成員只用包含一個內(nèi)部類的前置聲明和一個成員指針即可。而Impl
類則包含了原來類的所有真正的成員和函數(shù)實現(xiàn)酝豪。因為Impl
類可以實現(xiàn)在cpp文件中,所以達(dá)到了進(jìn)一步隱藏信息的效果精堕。
從上例我們看到孵淘,由于Storage
類的所有私有成員都轉(zhuǎn)移到了內(nèi)部的Impl
類中,所以Storage
類的頭文件中不再需要包含"StorageType.h"歹篓,只用在實現(xiàn)文件中包含即可瘫证。因此使用PIMPL手法,可以解決頭文件耦合與物理依賴傳遞的問題庄撮。
不過背捌,通過代碼示例也可以看到使用PIMPL方法是有成本的,它增加了間接函數(shù)調(diào)用和動態(tài)內(nèi)存分配的開銷洞斯。而且由于代碼多了一層封裝毡庆,導(dǎo)致整體復(fù)雜度上升了。因此除非解決某些嚴(yán)重的物理依賴問題烙如,一般不會大面積使用該手法么抗。
最后,一個完備的PIMPL實現(xiàn)會借助unique_ptr
類型的智能指針亚铁。本例為了簡化示例所以采用了裸指針實現(xiàn)蝇刀,更完整和通用的PIMPL實現(xiàn)可以參見 https://en.cppreference.com/w/cpp/language/pimpl。
到此徘溢,我們總結(jié)一下C/C++語言自身有關(guān)符號可見性控制的原則和方法:
1) 盡量避免使用extern關(guān)鍵字吞琐;
2) 對于C語言,盡可能多的使用static關(guān)鍵字來封裝細(xì)節(jié)然爆,讓代碼遵從Modular C的設(shè)計風(fēng)格站粟;
3)對于C++,盡可能多的使用private關(guān)鍵字施蜜;
4)對于C++卒蘸,盡可能使用命名空間來管理符號,尤其是使用匿名命名空間來隱藏符號;
5)頭文件盡量隱藏信息缸沃,縮小頭文件內(nèi)的符號可見性恰起。可以采取的手段有:
- 將類的靜態(tài)私有成員轉(zhuǎn)移到實現(xiàn)文件的匿名命名空間中趾牧;
- 在某些情況下检盼,可以將類的私有方法重構(gòu)成類的靜態(tài)私有方法,然后移入到實現(xiàn)文件的匿名命名空間中翘单;
- 對于某些嚴(yán)重的頭文件耦合問題吨枉,可以選擇使用PIMPL方法,隱藏類的所有非公開成員及其依賴的頭文件哄芜;