C++中由於有構造函數的概念,所以很多時候初始化工作能夠很方便地進行,而且由於C++標准庫中有很多實用類(往往是類模板),現代C++能十分容易地編寫。
比如現在要構造一個類Object,包含兩個字段,一個為整型,一個為字符串。C++的做法會像下面這樣
#include <stdio.h>
#include <string>
struct Object
{
int i;
std::string s;
Object(int _i, const char* _s) : i(_i), s(_s) { }
};
int main()
{
Object obj(1, "hello");
printf("%d %s\n", obj.i, obj.s.c_str());
return 0;
}
這樣的代碼簡潔、安全,C++通過析構函數來實現資源的安全釋放,string的c_str()方法能夠返回const char*,而這個字符串指針可能指向一片在堆上動態分配的內存,string的析構函數能夠保證string對象脫離作用域被銷毀時,這段內存被系統回收。
string真正實現較為復雜,它本身其實是類模板basic_string的實例化,而且basic_string里面的類型都是用type_traits來進行類型計算得到的類型別名,通過模板參數CharT(字符類型)不同,相應的值也不同,但都是通過模板的手法在編譯期就計算出來。比如字符類型CharT可以是char、char16_t、char32_t、wchar_t,對應的類模板實例化為string、u16string、u32string、wstring,共享類模板basic_string的成員函數來進行字符串操作。
string內部的優化措施也不同,像VS2015的basic_string就是采用字符串較短時c_str()指向棧上的字符數組、較長則動態分配的策略。其他系統有的可能采用寫時復制技術,總之,一般而言string不會成為性能的瓶頸,符合C++既保證代碼簡潔又保證抽象帶來的效率丟失盡可能小的設計要求。
對於C而言,就沒有C++那么方便了。C一般是直接用字符數組來表示字符串,再用頭文件<string.h>的函數來進行字符串操作。
字符數組是個麻煩東西,之前我寫過一篇博客討論數組與指針的區別。參見
數組比起包裝好的類,一個顯著差異就是在C/C++賦值符號“=”的使用上。參見下面代碼
std::string s1 = "hello"; std::string s2; s2 = s1; // OK! 調用成員函數operator= char s11[100] = "hello"; char s22[100]; // s22 = s11; // Error! 數組不能作為左值! strcpy(s22, s11); // OK! 調用C庫函數, 但實際中最好用strncpy來代替strcpy防止溢出
不過從上面代碼中也可以看出來C在語法上為字符數組提供了“特權”。正常來說數組可以用初始化列表(即用大括號括起來的若干元素)初始化
int a[] = { 1,2,3 };
但是字符數組像這樣初始化太麻煩,來體會一下
char s[] = { 'h', 'e', 'l', 'l', 'o' };
所以C可以直接用字符串字面值(string literal)來直接初始化字符數組
char s[] = "hello";
高下立判。(別看現在C語言的語法看起來這么原始,但其實C可是有不少“語法糖”的!)
不過這種做法僅限於初始化,在C/C++中必須得嚴格區分初始化和賦值,前者是給對象一個初始值,后者是對象已經有一個初始值,然后賦予一個新值。
再看看下面這份代碼
std::string s1 = "hello"; // 默認構造 auto s2 = s1; // 拷貝構造 s1 = s2; // 調用成員函數operator = char s11[] = "hello"; // 用字符串字面值來初始化字符數組 // char s22[] = s11; // Error! 數組只能以初始化列表或字符串字面值來初始化 // s22 = s11; // Error! 數組不能作為左值
但是C語言的結構體,對應C++的聚合類,跟普通類有所區別(具體參考C++ Primer 7.5.5),對“=”的支持就好得多
PS:聚合類屬於POD(Plain Old Data),之前看《STL源碼剖析》時對這個概念也是一知半解,包括后面針對trivial和non-trivial的模板偏特化。
#include <stdio.h>
typedef struct String
{
char s[100];
} String;
int main()
{
String s1 = { { "hello" } };
String s2 = s1;
puts(s2.s); // hello
s2.s[1] = '-';
s1 = s2;
puts(s1.s); // h-llo
return 0;
}
代碼方面注意main()函數第一行我用了兩層{},外層是用初始化列表初始化結構體,內層是用字符串字面值初始化數組。
兩處輸出的結果和預期的一樣,但是C語言沒有拷貝構造和運算符重載的概念啊,它是怎么做到的呢?
原因是C的賦值運算符就包含淺復制的特性,也就是說對於結構體而言,賦值操作會把等號右邊的變量的每一位給拷貝過去。如果結構體內包含的不是字符數組而是字符指針,那么僅僅是復制了地址,指向的都是內存上同一塊地址。
#include <stdio.h> #include <stdlib.h> #include <string.h> typedef struct String { char* s; } String; int main() { String s1 = { (char*)malloc(100) }; strncpy(s1.s, "hello", sizeof("hello")); String s2 = s1; s2.s[1] = '-'; puts(s1.s); // h-llo free(s1.s); return 0; }
注意,這里我用了動態分配,如果只是用字符串字面值的話,指針指向的區域(字符串字面值存儲在常量區)是不能更改的。在C++11中,只能用const char*指向字符串字面值,因為用char*指向它會有錯誤的語義,讓用戶以為這里指向的字符串可以修改。
從上面的例子可以看出,即使在所謂面向過程的C,用結構體這東西把變量包裝一下也能起到很好的作用,那么問題來了,回到最初的問題,用C語言實現最初的C++代碼一樣的功能該怎么去做呢?
於是C的“語法糖”又來了,C的結構體也支持初始化列表,因此可以像下面這樣
#include <stdio.h>
typedef struct Object
{
int i;
char s[100];
} Object;
int main()
{
Object obj = { 1, "hello" };
printf("%d %s\n", obj.i, obj.s);
return 0;
}
雖然Object占用空間很大(因為要保存字符數組緩存足夠大),並且對於真正較大的字符串這個結構體還是無用,只能動態分配。但是就現在要求實現的功能而言,這種做法是可行的,而且更為簡潔。(當然,C++用cout會更簡潔,不需要調用string::c_str()來取得const char*,但是我並不喜歡C++的I/O,先不說效率,就格式化輸出而言遠不如printf系列簡單,而且iostream默認與cstdio同步,導致速度很慢,關閉同步的話使用iostream和cstdio可能會出問題,二選一我當然選后者,雖然平常簡單測試的話混合用用也沒什么)
再提一下,之前說過這種類型在C++里屬於聚合類,也可以像C一樣用初始化列表進行初始化。
到此為止,C的代碼直接原封不動用C++的編譯方式是可以通過並運行的。
但是畢竟C的結構體不如C++的類方便,比如我現在只想初始化字符串,在C++里可以重載構造函數為Object(const char*)來解決,而C的初始化列表必須對結構體的所有變量依次初始化。對於早期C89標准,GNU提供了這兩種方便的初始化方式作為擴展
Object obj = { i : 1, s : "hello" }; printf("%d %s\n", obj.i, obj.s);
Object obj = { .i = 1, .s = "hello" }; printf("%d %s\n", obj.i, obj.s)
厲害了我的C,有了如此便捷且美觀的初始化方式,就不需要像C++一樣進行多種重載了。類成員變量過多的話,C++要實現靈活的初始化還是挺麻煩的。
假如對包含3個變量(x,y,z)的類,要實現對任意(0或1或2或3)個變量初始化,C++一共要對構造函數重載3^2=9次。而且假如3個變量都是int的話,初始化x和y以及初始化y和z的構造函數就無法區分了。
然並卵,實際應用哪會出現如此蛋疼的需求,就算有,也應該把多個變量個放進一個類里形成聚合類,一個良好的設計幾乎不會出現這種顧慮。
然而,這兩種方式在C++中均無法通過編譯,如下圖

因為我剛才提到了,那是GNU的擴展,並不屬於標准C。(雖然gcc編譯選項用-std=c89或-ansi也通過了編譯?)
但是,較新的C99標准支持了第二種做法,也就是可以寫出像下面這樣的代碼
struct sockaddr_in srvAddr = { .sin_family = AF_INET, .sin_port = htons(PORT), // PORT為自定義的宏,不再贅述 .sin_addr.s_addr = INADDR_ANY };
而如果是C++裸寫socket的話還得額外用個類來封裝下(像MFC就提供了CAsyncSocket),或者像這樣用舊式C風格的初始化方式
struct sockaddr_in srvAddr; srvAddr.sin_family = AF_INET; srvAddr.sin_port = htons(PORT); srvAddr.sin_addr.s_addr = INADDR_ANY;
即使這東西進了C99標准,還是不被C++支持。畢竟C++有構造函數,沒必要支持這種初始化方式,而且這里用列表初始化更簡單
struct sockaddr_in srvAddr = { AF_INET, htons(PORT), INADDR_ANY };
但這樣必須遵從變量在結構體中的順序,比如AF_INET和htons(PORT)順序反了的話雖然編譯會通過,但是運行就會出問題。而且這種代碼可讀性不好,不如老老實實用上面那種。
其實寫這篇博客主要是因為同學問了我一個類內聯合體初始化的問題,當時我認為是不能用字符串字面值來對字符數組賦值,后來發現是初始化,於是隱隱約約覺得不對,后來發現實際上是可以用來初始化的,只不過這種方式C++不支持導致編譯一直沒通過。(= =b)
C的做法類似這樣
#include <stdio.h>
struct Object
{
int i;
char s[100];
union {
int i;
char s[100];
} u;
};
int main()
{
struct Object obj = {
.s = "hello",
.u = { .s = "world" }
};
printf("%s %s\n", obj.s, obj.u.s);
return 0;
}
C++要做到同樣功能也可以,因為union也跟struct一樣,可以使用構造函數,不過對於類內的union必須顯式加析構函數。
這點之前我也糾結了半天,后來翻閱了C++ Primer,發現第19.6章有所提及。引用原文:
“如果union含有類類型的成員,並且該類型自定義了默認構造函數或拷貝控制成員,則編譯器將為union合成對應的版本並將其聲明為刪除的”
Primer也提到了早期C++標准是不允許union內部定義含有默認構造函數或拷貝控制成員的類,C++11標准取消了這個限制但是會把析構函數聲明為deleted(說白了就是要你寫析構函數,防止內存泄露,我這里使用標准庫的類所以不需要在析構函數里添加多余釋放內存的代碼)
#include <iostream>
#include <string>
struct Object
{
int i;
std::string s;
union {
int i;
std::string s;
U(const char* _s) : s(_s) { }
~U() { }
} u;
Object(const char* s1, const char* s2) : s(s1), u(s2) { }
};
int main()
{
Object obj("hello", "world");
std::cout << obj.s + " " + obj.u.s << std::endl;
return 0;
}
C的union大多時候起到一種隱式類型轉換的作用(&取地址,然后對指針類型進行強制轉換,然后*解引用)來實現C風格的多態,對於C++來說繼承、模板已經可以更優雅地實現這種功能,union的作用也就是節省空間了。
說到底都TM賴“兼容”!
