介紹
曾經碰到過讓你迷惑不解、類似於int * (* (*fp1) (int) ) [10];這樣的變量聲明嗎?本文將由易到難,一步一步教會你如何理解這種複雜的C/C++聲明:我們將從每天都能碰到的較簡單的聲明入手,然後逐步加入const修飾符和typedef,還有函數指針,最後介紹一個能夠讓你準確地理解任何C/C++聲明的“右左法則”。需要強調一下的是,複雜的C/C++聲明並不是好的編程風格;我這裏僅僅是教你如何去理解這些聲明。注意:爲了保證能夠在同一行上顯示代碼和相關注釋,本文最好在至少1024x768分辨率的顯示器上閱讀。
基礎
讓我們從一個非常簡單的例子開始,如下:
int n;
這個應該被理解爲“declare n as an int”(n是一個int型的變量)。
接下去來看一下指針變量,如下:
int *p;
這個應該被理解爲“declare p as an int *”(p是一個int *型的變量),或者說p是一個指向一個int型變量的指針。我想在這裏展開討論一下:我覺得在聲明一個指針(或引用)類型的變量時,最好將*(或&)寫在緊靠變量之前,而不是緊跟基本類型之後。這樣可以避免一些理解上的誤區,比如:
int* p,q;
第一眼看去,好像是p和q都是int*類型的,但事實上,只有p是一個指針,而q是一個最簡單的int型變量。
我們還是繼續我們前面的話題,再來看一個指針的指針的例子:
char **argv;
理論上,對於指針的級數沒有限制,你可以定義一個浮點類型變量的指針的指針的指針的指針...
再來看如下的聲明:
int RollNum[30][4];
int (*p)[4]=RollNum;
int *q[5];
這裏,p被聲明爲一個指向一個4元素(int類型)數組的指針,而q被聲明爲一個包含5個元素(int類型的指針)的數組。
另外,我們還可以在同一個聲明中混合實用*和&,如下:
int **p1; // p1 is a pointer to a pointer to an int.
int *&p2; // p2 is a reference to a pointer to an int.
int &*p3; // ERROR: Pointer to a reference is illegal.
int &&p4; // ERROR: Reference to a reference is illegal.
注:p1是一個int類型的指針的指針;p2是一個int類型的指針的引用;p3是一個int類型引用的指針(不合法!);p4是一個int類型引用的引用(不合法!)。
const修飾符
當你想阻止一個變量被改變,可能會用到const關鍵字。在你給一個變量加上const修飾符的同時,通常需要對它進行初始化,因爲以後的任何時候你將沒有機會再去改變它。例如:
const int n=5;
int const m=10;
上述兩個變量n和m其實是同一種類型的--都是const int(整形恆量)。因爲C++標準規定,const關鍵字放在類型或變量名之前等價的。我個人更喜歡第一種聲明方式,因爲它更突出了const修飾符的作用。
當const與指針一起使用時,容易讓人感到迷惑。例如,我們來看一下下面的p和q的聲明:
const int *p;
int const *q;
他們當中哪一個代表const int類型的指針(const直接修飾int),哪一個代表int類型的const指針(const直接修飾指針)?實際上,p和q都被聲明爲const int類型的指針。而int類型的const指針應該這樣聲明:
int * const r= &n; // n has been declared as an int
這裏,p和q都是指向const int類型的指針,也就是說,你在以後的程序裏不能改變*p的值。而r是一個const指針,它在聲明的時候被初始化指向變量n(即r=&n;)之後,r的值將不再允許被改變(但*r的值可以改變)。
組合上述兩種const修飾的情況,我們來聲明一個指向const int類型的const指針,如下:
const int * const p=&n // n has been declared as const int
下面給出的一些關於const的聲明,將幫助你徹底理清const的用法。不過請注意,下面的一些聲明是不能被編譯通過的,因爲他們需要在聲明的同時進行初始化。爲了簡潔起見,我忽略了初始化部分;因爲加入初始化代碼的話,下面每個聲明都將增加兩行代碼。
char ** p1; // pointer to pointer to char
const char **p2; // pointer to pointer to const char
char * const * p3; // pointer to const pointer to char
const char * const * p4; // pointer to const pointer to const char
char ** const p5; // const pointer to pointer to char
const char ** const p6; // const pointer to pointer to const char
char * const * const p7; // const pointer to const pointer to char
const char * const * const p8; // const pointer to const pointer to const char
注:p1是指向char類型的指針的指針;p2是指向const char類型的指針的指針;p3是指向char類型的const指針;p4是指向const char類型的const指針;p5是指向char類型的指針的const指針;p6是指向const char類型的指針的const指針;p7是指向char類型const指針的const指針;p8是指向const char類型的const指針的const指針。
typedef的妙用
typedef給你一種方式來克服“*只適合於變量而不適合於類型”的弊端。你可以如下使用typedef:
typedef char * PCHAR;
PCHAR p,q;
這裏的p和q都被聲明爲指針。(如果不使用typedef,q將被聲明爲一個char變量,這跟我們的第一眼感覺不太一致!)下面有一些使用typedef的聲明,並且給出瞭解釋:
typedef char * a; // a is a pointer to a char
typedef a b(); // b is a function that returns
// a pointer to a char
typedef b *c; // c is a pointer to a function
// that returns a pointer to a char
typedef c d(); // d is a function returning
// a pointer to a function
// that returns a pointer to a char
typedef d *e; // e is a pointer to a function
// returning a pointer to a
// function that returns a
// pointer to a char
e var[10]; // var is an array of 10 pointers to
// functions returning pointers to
// functions returning pointers to chars.
typedef經常用在一個結構聲明之前,如下。這樣,當創建結構變量的時候,允許你不使用關鍵字struct(在C中,創建結構變量時要求使用struct關鍵字,如struct tagPOINT a;而在C++中,struct可以忽略,如tagPOINT b)。
typedef struct tagPOINT
{
int x;
int y;
}POINT;
POINT p; /* Valid C co
函數指針
函數指針可能是最容易引起理解上的困惑的聲明。函數指針在DOS時代寫TSR程序時用得最多;在Win32和X-Windows時代,他們被用在需要回調函數的場合。當然,還有其它很多地方需要用到函數指針:虛函數表,STL中的一些模板,Win NT/2K/XP系統服務等。讓我們來看一個函數指針的簡單例子:
int (*p)(char);
這裏p被聲明爲一個函數指針,這個函數帶一個char類型的參數,並且有一個int類型的返回值。另外,帶有兩個float類型參數、返回值是char類型的指針的指針的函數指針可以聲明如下:
char ** (*p)(float, float);
那麼,帶兩個char類型的const指針參數、無返回值的函數指針又該如何聲明呢?參考如下:
void * (*a[5])(char * const, char * const);
“右左法則”[重要!!!]
The right-left rule: Start reading the declaration from the innermost parentheses, go right, and then go left. When you encounter parentheses, the direction should be reversed. On
這是一個簡單的法則,但能讓你準確理解所有的聲明。這個法則運用如下:從最內部的括號開始閱讀聲明,向右看,然後向左看。當你碰到一個括號時就調轉閱讀的方向。括號內的所有內容都分析完畢就跳出括號的範圍。這樣繼續,直到整個聲明都被分析完畢。
對上述“右左法則”做一個小小的修正:當你第一次開始閱讀聲明的時候,你必須從變量名開始,而不是從最內部的括號。
下面結合例子來演示一下“右左法則”的使用。
int * (* (*fp1) (int) ) [10];
閱讀步驟:
1. 從變量名開始 -------------------------------------------- fp1
2. 往右看,什麼也沒有,碰到了),因此往左看,碰到一個* ------ 一個指針
3. 跳出括號,碰到了(int) ----------------------------------- 一個帶一個int參數的函數
4. 向左看,發現一個* --------------------------------------- (函數)返回一個指針
5. 跳出括號,向右看,碰到[10] ------------------------------ 一個10元素的數組
6. 向左看,發現一個* --------------------------------------- 指針
7. 向左看,發現int ----------------------------------------- int類型
總結:fp1被聲明成爲一個函數的指針,該函數返回指向指針數組的指針.
再來看一個例子:
int *( *( *arr[5])())();
閱讀步驟:
1. 從變量名開始 -------------------------------------------- arr
2. 往右看,發現是一個數組 ---------------------------------- 一個5元素的數組
3. 向左看,發現一個* --------------------------------------- 指針
4. 跳出括號,向右看,發現() -------------------------------- 不帶參數的函數
5. 向左看,碰到* ------------------------------------------- (函數)返回一個指針
6. 跳出括號,向右發現() ------------------------------------ 不帶參數的函數
7. 向左,發現* --------------------------------------------- (函數)返回一個指針
8. 繼續向左,發現int --------------------------------------- int類型
總結:??
還有更多的例子:
float ( * ( *b()) [] )(); // b is a function that returns a
// pointer to an array of pointers
// to functions returning floats.
void * ( *c) ( char, int (*)()); // c is a pointer to a function that takes
// two parameters:
// a char and a pointer to a
// function that takes no
// parameters and returns
// an int
// and returns a pointer to void.
void ** (*d) (int &,
char **(*)(char *, char **)); // d is a pointer to a function that takes
// two parameters:
// a reference to an int and a pointer
// to a function that takes two parameters:
// a pointer to a char and a pointer
// to a pointer to a char
// and returns a pointer to a pointer
// to a char
// and returns a pointer to a pointer to void
float ( * ( * e[10])
(int &) ) [5]; // e is an array of 10 pointers to
// functions that take a single
// reference to an int as an argument
// and return pointers to
// an array of 5 floats.
本C/C++頭文件一覽
C、傳統 C++
#include <assert.h> //設定插入點
#include <ctype.h> //字符處理
#include <errno.h> //定義錯誤碼
#include <float.h> //浮點數處理
#include <fstream.h> //文件輸入/輸出
#include <iomanip.h> //參數化輸入/輸出
#include <iostream.h> //數據流輸入/輸出
#include <limits.h> //定義各種數據類型最值常量
#include <locale.h> //定義本地化函數
#include <math.h> //定義數學函數
#include <stdio.h> //定義輸入/輸出函數
#include <stdlib.h> //定義雜項函數及內存分配函數
#include <string.h> //字符串處理
#include <strstrea.h> //基於數組的輸入/輸出
#include <time.h> //定義關於時間的函數
#include <wchar.h> //寬字符處理及輸入/輸出
#include <wctype.h> //寬字符分類
//////////////////////////////////////////////////////////////////////////
標準 C++ (同上的不再註釋)
#include <algorithm> //STL 通用算法
#include <bitset> //STL 位集容器
#include <cctype>
#include <cerrno>
#include <clocale>
#include <cmath>
#include <complex> //複數類
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <deque> //STL 雙端隊列容器
#include <exception> //異常處理類
#include <fstream>
#include <functional> //STL 定義運算函數(代替運算符)
#include <limits>
#include <list> //STL 線性列表容器
#include <map> //STL 映射容器
#include <iomanip>
#include <ios> //基本輸入/輸出支持
#include <iosfwd> //輸入/輸出系統使用的前置聲明
#include <iostream>
#include <istream> //基本輸入流
#include <ostream> //基本輸出流
#include <queue> //STL 隊列容器
#include <set> //STL 集合容器
#include <sstream> //基於字符串的流
#include <stack> //STL 堆棧容器
#include <stdexcept> //標準異常類
#include <streambuf> //底層輸入/輸出支持
#include <string> //字符串類
#include <utility> //STL 通用模板類
#include <vector> //STL 動態數組容器
#include <cwchar>
#include <cwctype>
using namespace std;
//////////////////////////////////////////////////////////////////////////
C99 增加
#include <complex.h> //複數處理
#include <fenv.h> //浮點環境
#include <inttypes.h> //整數格式轉換
#include <stdbool.h> //布爾環境
#include <stdint.h> //整型環境
#include <tgmath.h> //通用類型數學宏
C++中的虛函數(virtual function)
1.簡介
虛函數是C++中用於實現多態(polymorphism)的機制。核心理念就是通過基類訪問派生類定義的函數。假設我們有下面的類層次:
class A
{
public:
virtual void foo() { cout << "A::foo() is called" << endl;}
};
class B: public A
{
public:
virtual void foo() { cout << "B::foo() is called" << endl;}
};
那麼,在使用的時候,我們可以:
A * a = new B();
a->foo(); // 在這裏,a雖然是指向A的指針,但是被調用的函數(foo)卻是B的!
這個例子是虛函數的一個典型應用,通過這個例子,也許你就對虛函數有了一些概念。它虛就虛在所謂“推遲聯編”或者“動態聯編”上,一個類函數的調用並不是在編譯時刻被確定的,而是在運行時刻被確定的。由於編寫代碼的時候並不能確定被調用的是基類的函數還是哪個派生類的函數,所以被成爲“虛”函數。
虛函數只能藉助於指針或者引用來達到多態的效果,如果是下面這樣的代碼,則雖然是虛函數,但它不是多態的:
class A
{
public:
virtual void foo();
};
class B: public A
{
virtual void foo();
};
void bar()
{
A a;
a.foo(); // A::foo()被調用
}
1.1 多態
在瞭解了虛函數的意思之後,再考慮什麼是多態就很容易了。仍然針對上面的類層次,但是使用的方法變的複雜了一些:
void bar(A * a)
{
a->foo(); // 被調用的是A::foo() 還是B::foo()?
}
因爲foo()是個虛函數,所以在bar這個函數中,只根據這段代碼,無從確定這裏被調用的是A::foo()還是B::foo(),但是可以肯定的說:如果a指向的是A類的實例,則A::foo()被調用,如果a指向的是B類的實例,則B::foo()被調用。
這種同一代碼可以產生不同效果的特點,被稱爲“多態”。
1.2 多態有什麼用?
多態這麼神奇,但是能用來做什麼呢?這個命題我難以用一兩句話概括,一般的C++教程(或者其它面嚮對象語言的教程)都用一個畫圖的例子來展示多態的用途,我就不再重複這個例子了,如果你不知道這個例子,隨便找本書應該都有介紹。我試圖從一個抽象的角度描述一下,回頭再結合那個畫圖的例子,也許你就更容易理解。
在面向對象的編程中,首先會針對數據進行抽象(確定基類)和繼承(確定派生類),構成類層次。這個類層次的使用者在使用它們的時候,如果仍然在需要基類的時候寫針對基類的代碼,在需要派生類的時候寫針對派生類的代碼,就等於類層次完全暴露在使用者面前。如果這個類層次有任何的改變(增加了新類),都需要使用者“知道”(針對新類寫代碼)。這樣就增加了類層次與其使用者之間的耦合,有人把這種情況列爲程序中的“bad smell”之一。
多態可以使程序員脫離這種窘境。再回頭看看1.1中的例子,bar()作爲A-B這個類層次的使用者,它並不知道這個類層次中有多少個類,每個類都叫什麼,但是一樣可以很好的工作,當有一個C類從A類派生出來後,bar()也不需要“知道”(修改)。這完全歸功於多態--編譯器針對虛函數產生了可以在運行時刻確定被調用函數的代碼。
1.3 如何“動態聯編”
編譯器是如何針對虛函數產生可以再運行時刻確定被調用函數的代碼呢?也就是說,虛函數實際上是如何被編譯器處理的呢?Lippman在深度探索C++對象模型[1]中的不同章節講到了幾種方式,這裏把“標準的”方式簡單介紹一下。
我所說的“標準”方式,也就是所謂的“VTABLE”機制。編譯器發現一個類中有被聲明爲virtual的函數,就會爲其搞一個虛函數表,也就是VTABLE。VTABLE實際上是一個函數指針的數組,每個虛函數佔用這個數組的一個slot。一個類只有一個VTABLE,不管它有多少個實例。派生類有自己的VTABLE,但是派生類的VTABLE與基類的VTABLE有相同的函數排列順序,同名的虛函數被放在兩個數組的相同位置上。在創建類實例的時候,編譯器還會在每個實例的內存佈局中增加一個vptr字段,該字段指向本類的VTABLE。通過這些手段,編譯器在看到一個虛函數調用的時候,就會將這個調用改寫,針對1.1中的例子:
void bar(A * a)
{
a->foo();
}
會被改寫爲:
void bar(A * a)
{
(a->vptr[1])();
}
因爲派生類和基類的foo()函數具有相同的VTABLE索引,而他們的vptr又指向不同的VTABLE,因此通過這樣的方法可以在運行時刻決定調用哪個foo()函數。
雖然實際情況遠非這麼簡單,但是基本原理大致如此。
1.4 overload和override
虛函數總是在派生類中被改寫,這種改寫被稱爲“override”。我經常混淆“overload”和“override”這兩個單詞。但是隨着各類C++的書越來越多,後來的程序員也許不會再犯我犯過的錯誤了。但是我打算澄清一下:
override是指派生類重寫基類的虛函數,就象我們前面B類中重寫了A類中的foo()函數。重寫的函數必須有一致的參數表和返回值(C++標準允許返回值不同的情況,這個我會在“語法”部分簡單介紹,但是很少編譯器支持這個feature)。這個單詞好象一直沒有什麼合適的中文詞彙來對應,有人譯爲“覆蓋”,還貼切一些。
overload約定成俗的被翻譯爲“重載”。是指編寫一個與已有函數同名但是參數表不同的函數。例如一個函數即可以接受整型數作爲參數,也可以接受浮點數作爲參數。
2. 虛函數的語法
虛函數的標誌是“virtual”關鍵字。
2.1 使用virtual關鍵字
考慮下面的類層次:
class A
{
public:
virtual void foo();
};
class B: public A
{
public:
void foo(); // 沒有virtual關鍵字!
};
class C: public B // 從B繼承,不是從A繼承!
{
public:
void foo(); // 也沒有virtual關鍵字!
};
這種情況下,B::foo()是虛函數,C::foo()也同樣是虛函數。因此,可以說,基類聲明的虛函數,在派生類中也是虛函數,即使不再使用virtual關鍵字。
2.2 純虛函數
如下聲明表示一個函數爲純虛函數:
class A
{
public:
virtual void foo()=0; // =0標誌一個虛函數爲純虛函數
};
一個函數聲明爲純虛後,純虛函數的意思是:我是一個抽象類!不要把我實例化!純虛函數用來規範派生類的行爲,實際上就是所謂的“接口”。它告訴使用者,我的派生類都會有這個函數。
2.3 虛析構函數
析構函數也可以是虛的,甚至是純虛的。例如:
class A
{
public:
virtual ~A()=0; // 純虛析構函數
};
當一個類打算被用作其它類的基類時,它的析構函數必須是虛的。考慮下面的例子:
class A
{
public:
A() { ptra_ = new char[10];}
~A() { delete[] ptra_;} // 非虛析構函數
private:
char * ptra_;
};
class B: public A
{
public:
B() { ptrb_ = new char[20];}
~B() { delete[] ptrb_;}
private:
char * ptrb_;
};
void foo()
{
A * a = new B;
delete a;
}
在這個例子中,程序也許不會象你想象的那樣運行,在執行delete a的時候,實際上只有A::~A()被調用了,而B類的析構函數並沒有被調用!這是否有點兒可怕?
如果將上面A::~A()改爲virtual,就可以保證B::~B()也在delete a的時候被調用了。因此基類的析構函數都必須是virtual的。
純虛的析構函數並沒有什麼作用,是虛的就夠了。通常只有在希望將一個類變成抽象類(不能實例化的類),而這個類又沒有合適的函數可以被純虛化的時候,可以使用純虛的析構函數來達到目的。
2.4 虛構造函數?
構造函數不能是虛的。
3. 虛函數使用技巧 3.1 private的虛函數
考慮下面的例子:
class A
{
public:
void foo() { bar();}
private:
virtual void bar() { ...}
};
class B: public A
{
private:
virtual void bar() { ...}
};
在這個例子中,雖然bar()在A類中是private的,但是仍然可以出現在派生類中,並仍然可以與public或者protected的虛函數一樣產生多態的效果。並不會因爲它是private的,就發生A::foo()不能訪問B::bar()的情況,也不會發生B::bar()對A::bar()的override不起作用的情況。
這種寫法的語意是:A告訴B,你最好override我的bar()函數,但是你不要管它如何使用,也不要自己調用這個函數。
3.2 構造函數和析構函數中的虛函數調用
一個類的虛函數在它自己的構造函數和析構函數中被調用的時候,它們就變成普通函數了,不“虛”了。也就是說不能在構造函數和析構函數中讓自己“多態”。例如:
class A
{
public:
A() { foo();} // 在這裏,無論如何都是A::foo()被調用!
~A() { foo();} // 同上
virtual void foo();
};
class B: public A
{
public:
virtual void foo();
};
void bar()
{
A * a = new B;
delete a;
}
如果你希望delete a的時候,會導致B::foo()被調用,那麼你就錯了。同樣,在new B的時候,A的構造函數被調用,但是在A的構造函數中,被調用的是A::foo()而不是B::foo()。
3.3 多繼承中的虛函數 3.4 什麼時候使用虛函數
在你設計一個基類的時候,如果發現一個函數需要在派生類裏有不同的表現,那麼它就應該是虛的。從設計的角度講,出現在基類中的虛函數是接口,出現在派生類中的虛函數是接口的具體實現。通過這樣的方法,就可以將對象的行爲抽象化。
以設計模式[2]中Factory Method模式爲例,Creator的factoryMethod()就是虛函數,派生類override這個函數後,產生不同的Product類,被產生的Product類被基類的AnOperation()函數使用。基類的AnOperation()函數針對Product類進行操作,當然Product類一定也有多態(虛函數)。
另外一個例子就是集合操作,假設你有一個以A類爲基類的類層次,又用了一個std::vector<A *>來保存這個類層次中不同類的實例指針,那麼你一定希望在對這個集合中的類進行操作的時候,不要把每個指針再cast回到它原來的類型(派生類),而是希望對他們進行同樣的操作。那麼就應該將這個“一樣的操作”聲明爲virtual。
現實中,遠不只我舉的這兩個例子,但是大的原則都是我前面說到的“如果發現一個函數需要在派生類裏有不同的表現,那麼它就應該是虛的”。這句話也可以反過來說:“如果你發現基類提供了虛函數,那麼你最好override它”。
4.參考資料
[1] 深度探索C++對象模型,Stanley B.Lippman,侯捷譯
[2] Design Patterns, Elements of Reusable Object-Oriented Software, GOF
C++程序設計從零開始之表達式
本篇是此係列的開頭,在學英語時,第一時間學的是字母,其是英語的基礎。同樣,在C++中,所有的代碼都是通過標識符(Identifier)、表達式(Expr
標識符
標識符是一個字母序列,由大小寫英文字母、下劃線及數字組成,用於標識。標識就是標出並識別,也就是名字。其可以作爲後面將提到的變量或者函數或者類等的名字,也就是說用來標識某個特定的變量或者函數或者類等C++中的元素。
比如:abc就是一個合法的標識符,即abc可以作爲變量、函數等元素的名字,但並不代表abc就是某個變量或函數的名字,而所謂的合法就是任何一個標識符都必須不能以數字開頭,只能包括大小寫英文字母、下劃線及數字,不能有其它符號,如,!^等,並且不能與C++關鍵字相同。也就是我們在給一個變量或函數起名字的時候,必須將起的名字看作是一個標識符,並進而必須滿足上面提出的要求。如12ab_C就不是一個合法的標識符,因此我們不能給某個變量或函數起12ab_C這樣的名字;ab_12C就是合法的標識符,因此可以被用作變量或函數的名字。
前面提到關鍵字,在後續的語句及一些聲明修飾符的介紹中將發現,C++提供了一些特殊的標識符作爲語句的名字,用以標識某一特定語句,如if、while等;或者提供一些修飾符用以修飾變量、函數等元素以實現語義或給編譯器及連接器提供一些特定信息以進行優化、查錯等操作,如extern、static等。因此在命名變量或函數或其他元素時,不能使用if、extern等這種C++關鍵字作爲名字,否則將導致編譯器無法確認是一個變量(或函數或其它C++元素)還是一條語句,進而無法編譯。
如果要讓某個標識符是特定變量或函數或類的名字,就需要使用聲明,在後續的文章中再具體說明。
數字
C++作爲電腦編程語言,電腦是處理數字的,因此C++中的基礎東西就是數字。C++中提供兩種數字:整型數和浮點數,也就是整數和小數。但由於電腦實際並不是想象中的數字化的(詳情參見《C++從零開始(三)》中的類型一節),所以整型數又分成了有符號和無符號整型數,而浮點數則由精度的區別而分成單精度和雙精度浮點數,同樣的整型數也根據長度分成長整型和短整型。
要在C++代碼中表示一個數字,直接書寫數字即可,如:123、34.23、-34.34等。由於電腦並非以數字爲基礎而導致了前面數字的分類,爲了在代碼中表現出來,C++提供了一系列的後綴進行表示,如下:
u或U 表示數字是無符號整型數,如:123u,但並不說明是長整型還是短整型
l或L 表示數字是長整型數,如:123l;而123ul就是無符號長整型數;而34.4l就是長雙精度浮點數,等效於雙精度浮點數
i64或I64 表示數字是長長整型數,其是爲64位操作系統定義的,長度比長整型數長。如:43i64
f或F 表示數字是單精度浮點數,如:12.3f
e或E 表示數字的次冪,如:34.4e-2就是0.344;0.2544e3f表示一個單精度浮點數,值爲254.4
當什麼後綴都沒寫時,則根據有無小數點及位數來決定其具體類型,如:123表示的是有符號整型數,而12341434則是有符號長整型數;而34.43表示雙精度浮點數。
爲什麼要搞這麼多事出來,還分什麼有符號無符號之類的?這全是因爲電腦並非基於數字的,而是基於狀態的,詳情在下篇中將詳細說明。
作爲科學計算,可能經常會碰到使用非十進制數字,如16進制、8進制等,C++也爲此提供了一些前綴以進行支持。
在數字前面加上0x或0X表示這個數字是16進製表示的,如:0xF3Fa、0x11cF。而在前面加一個0則表示這個數字是用8進製表示的,如: 0347,變爲十進制數就爲231。但16進制和8進制都不能用於表示浮點數,只能表示整型數,即0x34.343是錯誤的。
字符串
C++除了提供數字這種最基礎的表示方式外,還提供了字符及字符串。這完全只是出於方便編寫程序而提供的,C++作爲電腦語言,根本沒有提供字符串的必要性。不過由於人對電腦的基本要求就是顯示結果,而字符和字符串都由於是人易讀的符號而被用於顯示結果,所以C++專門提供了對字符串的支持。
前面說過,電腦只認識數字,而字符就是文字符號,是一種圖形符號。爲了使電腦能夠處理符號,必須通過某種方式將符號變成數字,在電腦中這通過在符號和數字之間建立一個映射來實現,也就是一個表格。表格有兩列,一列就是我們欲顯示的圖形符號,而另一列就是一個數字,通過這麼一張表就可以在圖形符號和數字之間建立映射。現在已經定義出一標準表,稱爲ASCII碼錶,幾乎所有的電腦硬件都支持這個轉換表以將數字變成符號進而顯示計算結果。
有了上面的表,當想說明結果爲“A”時,就查ASCII碼錶,得到“A”這個圖形符號對應的數字是65,然後就告訴電腦輸出序號爲65的字符,最後屏幕上顯示“A”。
這明顯地繁雜得異常,爲此C++就提供了字符和字符串。當我們想得到某一個圖形符號的ASCII碼錶的序號時,只需通過單引號將那個字符括起來即可,如:'A',其效果和65是一樣的。當要使用不止一個字符時,則用雙引號將多個字符括起來,也就是所謂的字符串了,如:"ABC"。因此字符串就是多個字符連起來而已。但根據前面的說明易發現,字符串也需要映射成數字,但它的映射就不像字符那麼簡單可以通過查表就搞定的,對於此,將在後續文章中對數組作過介紹後再說明。
操作符
電腦的基本是數字,那麼電腦的所有操作都是改變數字,因此很正常地C++提供了操作數字的一些基本操作,稱作操作符(Operator),如:+ - * / 等。任何操作符都要返回一個數字,稱爲操作符的返回值,因此操作符就是操作數字並返回數字的符號。作爲一般性地分類,按操作符同時作用的數字個數分爲一元、二元和三元操作符。
一元操作符有:
+ 其後接數字,原封不動地返回後接的數字。如: +4.4f的返回值是4.4;+-9.3f的返回值是-9.3。完全是出於語義的需要,如表示此數爲正數。
- 其後接數字,將後接的數字的符號取反。如: -34.4f的返回值是-34.4;-(-54)的返回值是54。用於表示負數。
! 其後接數字,邏輯取反後接的數字。邏輯值就是“真”或“假”,爲了用數字表示邏輯值,在 C++中規定,非零值即爲邏輯真,而零則爲邏輯假。因此3、43.4、'A'都表示邏輯真,而0則表示邏輯假。邏輯值被應用於後續的判斷及循環語句中。而邏輯取反就是先判斷“!”後面接的數字是邏輯真還是邏輯假,然後再將相應值取反。如:
!5的返回值是0,因爲先由5非零而知是邏輯真,然後取反得邏輯假,故最後返回0。
!!345.4的返回值是1,先因345.4非零得邏輯真,取反後得邏輯假,再取反得邏輯真。雖然只要非零就是邏輯真,但作爲編譯器返回的邏輯真,其一律使用1來代表邏輯真。
~ 其後接數字,取反後接的數字。取反是邏輯中定義的操作,不能應用於數字。爲了對數字應用取反操作,電腦中將數字用二進制表示,然後對數字的每一位進行取反操作(因爲二進制數的每一位都只能爲1或0,正好符合邏輯的真和假)。如~123的返回值就爲-124。先將123 轉成二進制數01111011,然後各位取反得10000100,最後得-124。
這裏的問題就是爲什麼是8位而不是16位二進制數。因爲123小於128,被定位爲char類型,故爲8位(關於char是什麼將下篇介紹)。如果是~123ul,則返回值爲4294967172。
爲什麼要有數字取反這個操作?因爲CPU提供了這樣的指令。並且其還有着很不錯且很重要的應用,後面將介紹。
關於其他的一元操作符將在後續文章中陸續提到(但不一定全部提到)。
二元操作符有:
+
-
*
/
%
其前後各接一數字,返回兩數字之和、差、積、商、餘數。如:
34+4.4f的返回值是38.4;3+-9.3f的返回值是-6.3。
34-4的返回值是30;5-234的返回值是-229。
3*2的返回值是6;10/3的返回值是3。
10%3的返回值是1;20%7的返回值是6。
&&
|| 其前後各接一邏輯值,返回兩邏輯值之“與”運算邏輯值和“或”運算邏輯值。如:
'A'&&34.3f的返回值是邏輯真,爲1;34&&0的返回值是邏輯假,爲0。
0||'B'的返回值是邏輯真,爲 1;0||0的返回值是邏輯假,爲0。
&
|
^ 其前後各接一數字,返回兩數字之“與”運算、“或”運算、“異或”運算值。如前面所說,先將兩側的數字轉成二進制數,然後對各位進行與、或、異或操作。如:
4&6的返回值是4,4轉爲00000100,6轉爲00000110各位相與得,00000100,爲4。
4|6的返回值是6,4轉爲00000100,6轉爲00000110各位相或得,00000110,爲6。
4^6的返回值是2,4轉爲00000100,6轉爲00000110各位相異或得,00000010,爲2。
>
<
==
>=
<=
!= 其前後各接一數字,根據兩數字是否大於、小於、等於、大於等於、小於等於及不等於而返回相應的邏輯值。如:
34>34的返回值是0,爲邏輯假;32<345的返回值爲1,爲邏輯真。
23>=23和23>=14的返回值都是1,爲邏輯真;54<=4的返回值爲0,爲邏輯假。
56==6的返回值是0,爲邏輯假;45==45的返回值是1,爲邏輯真。
5!=5的返回值是0,爲邏輯假;5!=35的返回值是真,爲邏輯真。
>>
<< 其前後各接一數字,將左側數字右移或左移右側數字指定的位數。與前面的 ~、&、|等操作一樣,之所以要提供左移、右移操作主要是因爲CPU提供了這些指令,主要用於編一些基於二進制數的算法。
<<將左側的數字轉成二進制數,然後將各位向左移動右側數值的位數,如:4,轉爲00000100,左移2位,則變成00010000,得16。
>>與<<一樣,只不過是向右移動罷了。如:6,轉爲00000110,右移1位,變成00000011,得3。如果移2位,則有一位超出,將截斷,則6>>2的返回值就是00000001,爲1。
左移和右移有什麼用?用於一些基於二進制數的算法,不過還可以順便作爲一個簡單的優化手段。考慮十進制數3524,我們將它左移2位,變成 352400,比原數擴大了100倍,準確的說應該是擴大了10的2次方倍。如果將3524右移2位,變成35,相當於原數除以100的商。
同樣,前面4>>2,等效於4/4的商;32>>3相當於32/8,即相當於32除以2的3次方的商。而4<<2等效於4*4,相當於4乘以2的2次方。因此左移和右移相當於乘法和除法,只不過只能是乘或除相應進制數的次方罷了,但它的運行速度卻遠遠高於乘法和除法,因此說它是一種簡單的優化手段。
, 其前後各接一數字,簡單的返回其右側的數字。如:
34.45f,54的返回值是54;-324,4545f的返回值是4545f。
那它到底有什麼用?用於將多個數字整和成一個數字,在《C++從零開始(四)》中將進一步說明。
關於其他的二元操作符將在後續文章中陸續提到(但不一定全部提到)。
三元操作符只有一個,爲?:,其格式爲:<數字1>?<數字2>:<數字3>。它的返回值爲:如果<數字1>是邏輯真,返回<數字2>,否則返回<數字3>。如:
34?4:2的返回值就是4,因爲34非零,爲邏輯真,返回4。而0?4:2的返回值就是2,因爲0爲邏輯假,返回2。
表達式
你應該發現前面的荒謬之處了——12>435返回值爲0,那爲什麼不直接寫0還吃飽了撐了寫個12>435在那?這就是表達式的意義了。
前面說“>”的前後各接一數字,但是操作符是操作數字並返回數字的符號,因爲它返回數字,因此可以放在上面說的任何一個要求接數字的地方,也就形成了所謂的表達式。如:23*54/45>34的返回值就是0,因爲23*54的返回值爲1242;然後又將1242作爲“/”的左接數字,得到新的返回值27.6;最後將27.6作爲“>”的左接數字進而得到返回值0,爲邏輯假。
因此表達式就是由一系列返回數字的東西和操作符組合而成的一段代碼,其由於是由操作符組成的,故一定返回值。而前面說的“返回數字的東西”則可以是另一個表達式,或者一個變量,或者一個具有返回值的函數,或者具有數字類型操作符重載的類的對象等,反正只要是能返回一個數字的東西。如果對於何謂變量、函數、類等這些名詞感到陌生,不需要去管它們,在後繼的文章中將會一一說明。
因此34也是一個表達式,其返回值爲34,只不過是沒有操作符的表達式罷了(在後面將會瞭解到34其實是一種操作符)。故表達式的概念其實是很廣的,只要有返回值的東西就可以稱爲表達式。
由於表達式裏有很多操作符,執行操作符的順序依賴於操作符的優先級,就和數學中的一樣,*、/的優先級大於+、-,而+、-又大於>、<等邏輯操作符。不用去刻意記住操作符的優先級,當不能確定操作符的執行順序時,可以使用小括號來進行指定。如:
((1+2)*3)+3)/4的返回值爲3,而1+2*3+3/4的返回值爲7。注意3/4爲0,因爲3/4的商是0。當希望進行浮點數除法或乘法時,只需讓操作數中的某一個爲浮點數即可,如:3/4.0的返回值爲0.75。
& | ^ ~等的應用
前面提過邏輯操作符“&&”、“||”、“!”等,作爲表示邏輯,其被C++提供一點都不值得驚奇。但是爲什麼要有一個將數字轉成二進制數,然後對二進制數的各位進行邏輯操作的這麼一類操作符呢?首先是CPU提供了相應的指令,並且其還有着下面這個非常有意義的應用。
考慮一十字路口,每個路口有三盞紅綠燈,分別指明能否左轉、右轉及直行。共有12盞,現在要爲它編寫一個控制程序,不管這程序的功能怎樣,首先需要將紅綠燈的狀態轉化爲數字,因爲電腦只知道數字。所以用3個數字分別表示某路口的三盞紅綠燈,因此每個紅綠燈的狀態由一個數字來表示,假設紅燈爲0,綠燈爲1(不考慮黃燈或其他情況)。
後來忽然發現,其實也可以用一個數字表示一個路口的三盞紅綠燈狀態,如用110表示左轉綠燈、直行綠燈而右轉紅燈。上面的110是一個十進制數字,它的每一位實際都可以爲0~9十個數字,但是這裏只應用到了兩個:0和1,感覺很浪費。故選擇二進制數來表示,還是110,但是是二進制數了,轉成十進制數爲6,即使當爲111時轉成十進制數也只是7,比前面的110這個十進制數小多了,節約了……??什麼??
我們在紙上寫數字235425234一定比寫134這個數字要更多地佔用紙張(假設字都一樣大)。因此記錄一個大的數比記錄一個小的數要花費更多的資源。簡直荒謬!不管是100還是1000,都只是一個數字,爲什麼記錄大的數字就更費資源?因爲電腦並不是數字計算機,而是電子計算機,它是基於狀態而不是基於數字的,這在下篇會詳細說明。電腦必須使用某種表示方式來代表一個數字,而那個表示方式和二進制很像,但並不是二進制數,故出現記錄大的數較小的數更耗資源,這也就是爲什麼上面整型數要分什麼長整型短整型的原因了。
下面繼續上面的思考。使用了110這個二進制數來表示三盞紅綠燈的狀態,那麼現在要知道110這個數字代表左轉紅綠燈的什麼狀態。以數字的第三位表示左轉,不過電腦並不知道這個,因此如下:110&100。這個表達式的返回值是100,非零,邏輯真。假設某路口的狀態爲010,則同樣的010&100,返回值爲0,邏輯假。因此使用“&”操作符可以將二進制數中的某一位或幾位的狀態提取出來。所以我們要了解一個數字代表的紅綠燈狀態中的左轉紅綠燈是否綠燈時,只需讓它和100相與即可。
現在要保持其他紅綠燈的狀態不變,僅僅使左轉紅綠燈爲綠燈,如當前狀態爲010,爲了使左轉紅綠燈爲綠燈,值應該爲110,這可以通過010|100做到。如果當前狀態是001,則001|100爲101,正確——直行和右轉的紅綠燈狀態均沒有發生變化。因此使用“|”操作符可以給一個二進制數中的某一位或幾位設置狀態,但只能設置爲1,如果想設置爲0,如101,要關掉左轉的綠燈,則101&~100,返回值爲001。
上面一直提到的路口紅綠燈的狀態實際編寫時可以使用一個變量來表示,而上面的100也可以用一個標識符來表示,如state&TS_LEFT,就可以表示檢查變量state所表示的狀態中的左轉紅綠燈的狀態。
上面的這種方法被大量地運用,如創建一個窗口,一個窗口可能有二三十個風格,則通過上面的方法,就可以只用一個32位長的二進制數字就表示了窗口的風格,而不用去弄二三十個數字來分別代表每種風格是否具有。
本用C++實現簡單的文件I/O操作
文件 I/O 在C++中比烤蛋糕簡單多了。 在這篇文章裏,我會詳細解釋ASCII和二進制文件的輸入輸出的每個細節,值得注意的是,所有這些都是用C++完成的。
一、ASCII 輸出
爲了使用下面的方法, 你必須包含頭文件<fstream.h>(譯者注:在標準C++中,已經使用<fstream>取代< fstream.h>,所有的C++標準頭文件都是無後綴的。)。這是 <iostream.h>的一個擴展集, 提供有緩衝的文件輸入輸出操作. 事實上, <iostream.h> 已經被<fstream.h>包含了, 所以你不必包含所有這兩個文件, 如果你想顯式包含他們,那隨便你。我們從文件操作類的設計開始, 我會講解如何進行ASCII I/O操作。如果你猜是"fstream," 恭喜你答對了! 但這篇文章介紹的方法,我們分別使用"ifstream"?和 "ofstream" 來作輸入輸出。
如果你用過標準控制檯流"cin"?和 "cout," 那現在的事情對你來說很簡單。 我們現在開始講輸出部分,首先聲明一個類對象。ofstream fout;
這就可以了,不過你要打開一個文件的話, 必須像這樣調用ofstream::open()。
fout.open("output.txt");
你也可以把文件名作爲構造參數來打開一個文件.
ofstream fout("output.txt");
這是我們使用的方法, 因爲這樣創建和打開一個文件看起來更簡單. 順便說一句, 如果你要打開的文件不存在,它會爲你創建一個, 所以不用擔心文件創建的問題. 現在就輸出到文件,看起來和"cout"的操作很像。 對不瞭解控制檯輸出"cout"的人, 這裏有個例子。
int num = 150;
char name[] = "John Doe";
fout << "Here is a number: " << num << "\n";
fout << "Now here is a string: " << name << "\n";
現在保存文件,你必須關閉文件,或者回寫文件緩衝. 文件關閉之後就不能再操作了, 所以只有在你不再操作這個文件的時候才調用它,它會自動保存文件。 回寫緩衝區會在保持文件打開的情況下保存文件, 所以只要有必要就使用它。回寫看起來像另一次輸出, 然後調用方法關閉。像這樣:
fout << flush; fout.close();
現在你用文本編輯器打開文件,內容看起來是這樣:
Here is a number: 150 Now here is a string: John Doe
很簡單吧! 現在繼續文件輸入, 需要一點技巧, 所以先確認你已經明白了流操作,對 "<<" 和">>" 比較熟悉了, 因爲你接下來還要用到他們。繼續…
二、ASCII 輸入
輸入和"cin" 流很像. 和剛剛討論的輸出流很像, 但你要考慮幾件事情。在我們開始複雜的內容之前, 先看一個文本:
12 GameDev 15.45 L This is really awesome!
爲了打開這個文件,你必須創建一個in-stream對象,?像這樣。
ifstream fin("input.txt");
現在讀入前四行. 你還記得怎麼用"<<" 操作符往流裏插入變量和符號吧?好,?在 "<<" (插入)?操作符之後,是">>" (提取) 操作符. 使用方法是一樣的. 看這個代碼片段.
int number;
float real;
char letter, word[8];
fin >> number; fin >> word; fin >> real; fin >> letter;
也可以把這四行讀取文件的代碼寫爲更簡單的一行。
fin >> number >> word >> real >> letter;
它是如何運作的呢? 文件的每個空白之後, ">>" 操作符會停止讀取內容, 直到遇到另一個>>操作符. 因爲我們讀取的每一行都被換行符分割開(是空白字符), ">>" 操作符只把這一行的內容讀入變量。這就是這個代碼也能正常工作的原因。但是,可別忘了文件的最後一行。
This is really awesome!
如果你想把整行讀入一個char數組, 我們沒辦法用">>"?操作符,因爲每個單詞之間的空格(空白字符)會中止文件的讀取。爲了驗證:
char sentence[101]; fin >> sentence;
我們想包含整個句子, "This is really awesome!" 但是因爲空白, 現在它只包含了"This". 很明顯, 肯定有讀取整行的方法, 它就是getline()。這就是我們要做的。
fin.getline(sentence, 100);
這是函數參數. 第一個參數顯然是用來接受的char數組. 第二個參數是在遇到換行符之前,數組允許接受的最大元素數量. 現在我們得到了想要的結果:“This is really awesome!”。
你應該已經知道如何讀取和寫入ASCII文件了。但我們還不能罷休,因爲二進制文件還在等着我們。
三、二進制 輸入輸出
二進制文件會複雜一點, 但還是很簡單的。首先你要注意我們不再使用插入和提取操作符(譯者注:<< 和 >> 操作符). 你可以這麼做,但它不會用二進制方式讀寫。你必須使用read() 和write() 方法讀取和寫入二進制文件. 創建一個二進制文件, 看下一行。
ofstream fout("file.dat", ios::binary);
這會以二進制方式打開文件, 而不是默認的ASCII模式。首先從寫入文件開始。函數write() 有兩個參數。 第一個是指向對象的char類型的指針, 第二個是對象的大小(譯者注:字節數)。 爲了說明,看例子。
int number = 30; fout.write((char *)(&number), sizeof(number));
第一個參數寫做"(char *)(&number)". 這是把一個整型變量轉爲char *指針。如果你不理解,可以立刻翻閱C++的書籍,如果有必要的話。第二個參數寫作"sizeof(number)". sizeof() 返回對象大小的字節數. 就是這樣!
二進制文件最好的地方是可以在一行把一個結構寫入文件。 如果說,你的結構有12個不同的成員。 用ASCII?文件,你不得不每次一條的寫入所有成員。 但二進制文件替你做好了。 看這個。
struct OBJECT { int number; char letter; } obj;
obj.number = 15;
obj.letter = ‘M’;
fout.write((char *)(&obj), sizeof(obj));
這樣就寫入了整個結構! 接下來是輸入. 輸入也很簡單,因爲read()?函數的參數和 write()是完全一樣的, 使用方法也相同。
ifstream fin("file.dat", ios::binary); fin.read((char *)(&obj), sizeof(obj));
我不多解釋用法, 因爲它和write()是完全相同的。二進制文件比ASCII文件簡單, 但有個缺點是無法用文本編輯器編輯。 接着, 我解釋一下ifstream 和ofstream 對象的其他一些方法作爲結束.
四、更多方法
我已經解釋了ASCII文件和二進制文件, 這裏是一些沒有提及的底層方法。
檢查文件
你已經學會了open() 和close() 方法, 不過這裏還有其它你可能用到的方法。
方法good() 返回一個布爾值,表示文件打開是否正確。
類似的,bad() 返回一個布爾值表示文件打開是否錯誤。 如果出錯,就不要繼續進一步的操作了。
最後一個檢查的方法是fail(), 和bad()有點相似, 但沒那麼嚴重。
讀文件
方法get() 每次返回一個字符。
方法ignore(int,char) 跳過一定數量的某個字符, 但你必須傳給它兩個參數。第一個是需要跳過的字符數。 第二個是一個字符, 當遇到的時候就會停止。例子,
fin.ignore(100, ‘\n’);
會跳過100個字符,或者不足100的時候,跳過所有之前的字符,包括 ‘\n’。
方法peek() 返回文件中的下一個字符, 但並不實際讀取它。所以如果你用peek() 查看下一個字符, 用get() 在peek()之後讀取,會得到同一個字符, 然後移動文件計數器。
方法putback(char) 輸入字符, 一次一個, 到流中。我沒有見到過它的使用,但這個函數確實存在。
寫文件
只有一個你可能會關注的方法.?那就是 put(char), 它每次向輸出流中寫入一個字符。
打開文件
當我們用這樣的語法打開二進制文件:
ofstream fout("file.dat", ios::binary);
"ios::binary"是你提供的打開選項的額外標誌. 默認的, 文件以ASCII方式打開, 不存在則創建, 存在就覆蓋. 這裏有些額外的標誌用來改變選項。
ios::app 添加到文件尾
ios::ate 把文件標誌放在末尾而非起始。
ios::trunc 默認. 截斷並覆寫文件。
ios::nocreate 文件不存在也不創建。
ios::noreplace 文件存在則失敗。
文件狀態
我用過的唯一一個狀態函數是eof(), 它返回是否標誌已經到了文件末尾。 我主要用在循環中。例如, 這個代碼斷統計小寫‘e’ 在文件中出現的次數。
ifstream fin("file.txt");
char ch; int counter;
while (!fin.eof()) {
ch = fin.get();
if (ch == ‘e’) counter++;
}
fin.close();
我從未用過這裏沒有提到的其他方法。 還有很多方法,但是他們很少被使用。參考C++書籍或者文件流的幫助文檔來了解其他的方法。
結論
你應該已經掌握瞭如何使用ASCII文件和二進制文件。有很多方法可以幫你實現輸入輸出,儘管很少有人使用他們。我知道很多人不熟悉文件I/O操作,我希望這篇文章對你有所幫助。 每個人都應該知道. 文件I/O還有很多顯而易見的方法,?例如包含文件 <stdio.h>. 我更喜歡用流是因爲他們更簡單。 祝所有讀了這篇文章的人好運, 也許以後我還會爲你們寫些東西。
C++操作符重載的變態用途之子類轉換
如果類的成員變量是特定類和自定義結構,使用該類名或結構作爲操作符進行重載。(當然是基本類型也可以,不過實用性不強,只會降低代碼可讀性。)
如下,一個CPerson,強行轉換爲hand,也可以使用。
類似於現實,我們只會對某個實物的具體特徵表示強烈的興趣,也就是特徵聚焦的意思。如HR部門只會關注一個應聘者的skill。
當然在實際用途中,過度使用這種子類轉換,只會降低代碼可讀性。
另外如類中有多個同類型的成員,這樣的轉換讓人莫名其妙。
實例代碼:
// Person.h: interface for the CPerson class.
//
//////////////////////////////////////////////////////////////////////
#if !defined(AFX_PERSON_H__A825C71F_CB10_4997_8F9C_DBE792C5C387__INCLUDED_)
#define AFX_PERSON_H__A825C71F_CB10_4997_8F9C_DBE792C5C387__INCLUDED_
#if _MSC_VER > 1000
#pragma on
#endif // _MSC_VER > 1000
typedef struct tag_hand
{
bool bSix;
bool bLefty;
} hand;
class CSkill
{
public:
CSkill():strDesc(NULL){}
virtual ~CSkill(){}
public:
char *strDesc;
};
class CPerson
{
public:
CPerson();
virtual ~CPerson();
hand m_hand;
CSkill m_skill;
operator hand() const;
operator CSkill() const;
static void Test();
};
#endif // !defined(AFX_PERSON_H__A825C71F_CB10_4997_8F9C_DBE792C5C387__INCLUDED_)
// Person.cpp: implementation of the CPerson class.
//
//////////////////////////////////////////////////////////////////////
#include "stdafx.h"
#include "Person.h"
//////////////////////////////////////////////////////////////////////
// Construction/Destruction
//////////////////////////////////////////////////////////////////////
CPerson::CPerson()
{}
CPerson::~CPerson()
{}
CPerson::operator hand() const
{
return m_hand;
}
CPerson::operator CSkill() const
{
return m_skill;
}
void CPerson::Test()
{
CPerson person;
person.m_hand.bSix = false;
person.m_hand.bLefty = true;
person.m_skill.strDesc = new char[1024];
strcpy( person.m_skill.strDesc, "Good at programming..." );
printf( "%d, %d\n", ((hand)person).bSix, ((hand)person).bLefty );
printf( "%s\n", ((CSkill)person).strDesc );
delete[] person.m_skill.strDesc;
return;
}
int main(int argc, char* argv[])
{
CPerson::Test();
return 0;
}
輸出:
0, 1
Good at programming...
C/C++中的整型常識
很多人對C/C++中的整型不太瞭解,導致代碼移植的時候出現問題,本人在此總結一下,若有描述錯誤,請務必指出,謝謝!
a. C/C++對整型長度的規定是爲了執行效率,將int定義爲機器字長可以取得最大的執行速度;
b. C/C++中整型包括:int, char 和 enum, C++中還包含bool類型,C99中bool是一個宏,實際爲_Bool;
c. C 和 C++ 對 enum 的規定有所不同,這裏不描述;
d. 修飾整型正負的有 signed 和 unsigned,對於 int 默認爲 signed;
e. 修飾 int 大小的有 short 和 long, 部分編譯器還擴展了一些更長的整型,比如 long long 和 __int64, C99中增加了long long和unsigned long long;
f. int 的長度 與 機器字長相同, 16位的編譯器上int長16位,32位的編譯器上int長32位;
g. short int 的長度 小於等於 int 的長度,注意她們可能長度相等,這取決於編譯器;
h. long int 的長度 大於等於 int 的長度,注意她們可能長度相等,這取決於編譯器;
i. char 的長度應當可以包容得下一個字符,大部分系統中就是一個字節,而有的系統中可能是4個字節,因爲這些系統中一個字符需要四個字節來描述;
j. char 的正負取決於編譯器,而編譯器的決定取決於操作系統,在不同的編譯器中char可能等同於signed char,也可能等同於unsigned char;
總結:
a. 出於效率考慮,應該儘量使用int和unsigned int;
b. 當需要指定容量的整型時,不應該直接使用short、int、long等,因爲在不同的編譯器上她們的容量不相同。此時應該定義她們相應的宏或類型,比如在VC++6.0中,可以如下定義:
typedef unsigned char UBYTE;
typedef signed char SBYTE;
typedef unsigned short int UWORD;
typedef signed short int SWORD;
typedef unsigned int UDWORD;
typedef signed int SDWORD;
typedef unsigned __int64 UQWORD;
typedef signed __int64 SQWORD;
然後在代碼中使用 UBYTE、SBYTE、UWORD 等,這樣當代碼移植的時候只需要修改相應的類型即可。
定義自己的類型雖然在代碼移植的時候只需要修改一處即可,但仍然屬於源代碼級別的修改,所以 C++ 2.0 中將這些類型定義在模板中,可以做到代碼移植時無需修改代碼。
c. 在定義char時,一定要加上 signed 或 unsigned,因爲她的正負在不同的編譯器上並不相同。
d. 不要想當然的以爲char是1字節長,因爲她的長度在不同的編譯器上並不相同。
從C++到.NET 揭開多態的面紗
多態是面向對象理論中的重要概念之一,從而也成爲現代程序設計語言的一個主要特性,從應用角度來說,多態是構建高靈活性低耦合度的現代應用程序架構所不可忽缺的能力。從概念的角度來說,多態使得程序員可以不必關心某個對象的具體類型,就可以使用這個對象的“某一部分”功能。這個“某一部分”功能可以用基類來呈現,也可以用接口來呈現。後者顯得更爲重要——接口是使程序具有可擴展性的重要特性,而接口的實現依賴於語言對多態的實現,或者乾脆就象徵着語言對多態的實現。
本文並不大算贅述多態的應用,因爲其應用實在俯拾皆是,其概念理論也早已完善。這裏,我們打算從實現的角度來看一看一門語言在其多態特性的背後做了些什麼——知其所以然,使用時方能遊刃有餘。
或許你在學習一門語言的時候,曾經對多態的特性很迷惑,雖然教科書上所講的非常簡單,也非常明瞭——正如它的原本理念一樣,但是你也想知道語言(編譯器)在背後都幹了些什麼,爲什麼一個派生類對象就可以被當作其基類對象來使用?用指向派生類對象的基類指針調用虛函數時憑什麼能夠精確的到達正確的函數?類的內部是如何佈局的?
我們這樣考慮:假設語言不支持多態,而我們又必須實現多態,我們可以怎麼做?
多態的雛形:
class B
{
public:
int flag; //爲表示簡潔,0代表基類,1代表派生類
void f(){cout<<”in B::f()”;} //非虛函數
};
class D:public B
{
public:
void f(){cout<<”in D::f()”;} //非虛函數
};
void call_virtual(B* pb)
{
if(pb->flag==0) //如果是基類,則直接調用f
pb->f(); //調用的是基類的f
else //如果是派生類,則強制轉化爲派生類指針再調用f
(D*)pb->f(); //調用的是派生類的f
}
這樣,可以正好符合“根據具體的對象類型調用相應的函數”的理念。但是這個原始方案有一些缺點:;例如,分發“虛函數”的代碼要自己書寫,不夠優雅,不具有可擴展性(當繼承體系擴大時,這堆代碼將變得臃腫無比),不具有封閉性(如果加入了一個新的派生類,則“虛函數”調用的代碼必須作改動,然而如果恰巧這個調用是無法改動的(例如,庫函數),則意味着,一個用戶加入的派生類將無法兼容於那個庫函數)等等。結果就是——這個方案不具有通用性。
但是,這個方案能夠說明一些本質性的問題:flag數據成員用於標識對象所屬的具體類型,從而調用者可以根據它來確定到底調用哪個函數。但是,可不可以不必“知道”對象的具體類型就能夠調用正確的函數呢?可以,改進的方案如下:
class B
{
public:
void (*f)(); //函數指針,派生類對象可以通過給它重新賦值來改變對象的行爲
};
class D:public B
{};
void call_virtual(B* pb)
{
(*(pb->f))(); //間接調用f所指的函數
}
void B_Mem()
{
cout<<”I am B”;
}
void D_Mem()
{
cout<<”I am D”;
}
int main()
{
B b;
b.f=&B_Mem; //B_Mem代表B的“虛函數”
D d;
d.f=&D_Mem; //以D_Mem來覆蓋(override)B的虛函數
call_virtual(&b); //輸出“I am B”
call_virtual(&d); //輸出“I am D”
}
在這個改進的例子中,派生類對象可以通過修改函數指針f的指向,從而獲得特定的行爲,這裏重要的是,call_virtual函數不再需要通過醜陋的if-else語句來判斷對象的具體類型,而只是簡單的通過一個指針來調用“虛函數”——這時候,如果派生類需要改變具體的行爲,則可以將相應的函數指針指向它自己的函數即可,這招“偷樑換柱”通過增加一個間接層的辦法“神不知鬼不覺”地將“虛函數”替換(Override)掉了。
然而,這招仍然還有缺點——要用戶手動實現,可擴展性差,透明性差等等。然而,它的思想已經接近現代編譯器對多態機制的實現手法了。
通過將上面的例子中的函數指針擴展爲一個隱含的指針數組——虛函數表(vtbl)——C++擁有了我們現在所看到的多態能力。在虛函數表中,每一個虛函數指針佔有一個表項,如果派生類覆蓋(override)了相應的虛函數,則對應表項就改成指向派生類的那個虛函數的——這些工作由編譯器完成——從而,如上例所示,用戶不必知曉對象的確切類型,就能夠觸發其特定的行爲(也就是說,調用“取決於對象具體類型”的成員函數),虛函數表對用戶是完全透明的,用戶只需要使用一個virtual關鍵字就能夠輕鬆擁有強大的多態能力。
如果一個C++類中有虛函數,則該類將會擁有一個虛函數表(vtbl),並且,該類的對象中(一般在頭部)有一個隱含的指向虛函數表的指針(vptr)。
現在假設有如下代碼:
void f(B* pb)
{
pb->f1();
}
則編譯器爲該函數生成的代碼如下(以僞代碼表示,以示明瞭):
void f(B* pb)
{
DWORD* __vptr=((DWORD*)pb)[0]; //獲得虛函數表指針
void (B::*midd_pf)()=__vptr[offsetof_virtual_pf1];
//從表中獲得相應虛函數指針
(pb->*midd_pf)(); //調用虛函數
}
這樣一來,如果pb指向的是D對象,則獲得的是指向D::f1的函數指針(參考上面的第二幅圖),如果pb確實指向B對象,根據B對象內的vptr所指的虛函數表,獲得的是指向B::f1的函數指針。
現在,關於C++的多態機制基本已經明瞭。剩下的就是多重繼承下的虛函數表格局,大同小異,就不多說了。只不過,其中還是有一些微妙的細節的,可以參見《Inside C++ Object Model》(Lippman著)(中文名《深入C++對象模型》——侯捷譯)。
關於C++虛函數調用機制還有一個細節——在構造函數中調用虛函數要千萬小心,因爲“在構造函數中”意味着“對象還沒有構造完畢”,這時候虛函數調用機制很可能還沒有啓動,例如:
class B
{
B(){this->vf();} //調用B::vf
virtual void vf(){cout<<”in B::vf()\n”;
};
現在,不管B身爲哪個類的基類,B的構造函數中調用的都是B::vf。細心的讀者會發現:這是由於對象構造順序的關係——C++明確規定,對象的“大廈”是“自底向上”構建的,也就是說,從最底層的基類開始構造,所以,在B中調用this->vf時,雖然this所指的對象確實(即將)是派生類對象,但是派生類對象的構建行爲還沒有開始,所以這次調用不可能跑到派生類的vf函數去,就好像第二層樓還沒有建好,一層樓的人是無法跑到二樓去的一樣。
說得更深一些,虛函數的調用是要經過虛函數指針和虛函數表來間接推導的,在B的構造函數中,編譯器會插入一些代碼,將對象頭部的vptr設置爲指向B的虛函數表的指針,於是this->vf的推導使用的是B的虛函數表,當然只能跑到B的vf那兒去。而後來,當B構建完畢,輪到派生類對象部分構造時,派生類的構造函數會將對象頭部的vptr改成指向派生類的虛函數表的指針,這時候虛函數調用機制纔算是Enable了,以後的this->vf將使用派生類虛函數表來推導,從而到達正確的函數。
.NET 對象模型
C++對象模型與.NET(或Java)有個主要的區別——C++支持多重繼承,不支持接口,而.NET(或Java)支持接口,不支持多重繼承。
而.NET的虛函數調用機制與C++也比較相似,只不過由於接口和JIT(即時編譯)的介入而有一些不同。
在.NET中,每一個類都有一個對應的函數指針表(事實上,這個“表”是個數據結構,裏面還有其它信息),與C++不同的是,該類的每個函數(不管是不是虛函數)都在其中對應一個表項。這是由於JIT(即時編譯)的需要——對每個函數的調用都是間接的,都會經過該表推導一次,獲得函數代碼的地址。注意,第一次調用的時候,函數代碼還是中間代碼(.NET的中間語言MISL的代碼),所以將會跳至即時編譯器,編譯這些代碼並放到內存中,然後將表中的對應表項指向編譯後的native co
以上只是想讓你對.NET的“虛函數表”有個大體的認識。下面就來詳細剖析。
如果沒有接口,.NET的虛函數調用機制將是很單純的——幾乎與C++一樣。只不過,接口加入以後就不同了——可以將對象引用轉化爲接口引用,然後再調用接口中的虛函數。所以,勢必要對“虛函數表”作某種改動,例如,對於下面的繼承結構:
public interface IFirst
{
void f1();
void f2();
}
public interface ISecond
{
void s1();
}
public class C:IFirst,Isecond
{
public override void f1(){}
public override void f2(){}
public override void s1(){}
public virtual void c1(){}
}
類型C的內存佈局大體是這樣的(由於.NET是單根的繼承結構,每個類都隱式的繼承自Object,所以,類型C的“虛函數表”中包含Object的所有成員函數)
ObjRef指向一個對象,在對象頂部(除了用於同步的sync#塊之外)是hType(可以看成對應於C++對象頂部的虛函數表指針),它所指的結構(CORINFO_CLASS_STRUCT,可以暫時將它看成虛函數表,儘管其中包含的信息不僅僅是虛函數指針)包含在C++中相當於虛函數表的部分,以及用於對象的運行時識別的信息。不同的是,在基於接口的.NET繼承風格中,對接口的虛函數的分派是基於一個IOT(Interface Offset Table,即接口偏移表),圖中的pIOT就是指向這樣一個表,其中每一項都是一個偏移量,反指向該接口中的虛函數指針數組在CORINFO_CLASS_STRUCT中的位置。
這樣,當基於接口的引用調用虛函數時,其背後的機制是:先根據接口引用取得該類所對應的CORINFO_CLASS_STRUCT結構的地址,然後在pIOT所指的接口偏移表中索引相應的虛函數指針數組的偏移量,最後經過指針間接調用虛函數。 可以看出,基於接口引用調用虛函數時要經過兩個間接層,第一,在IOT中索引對應接口的虛函數指針數組的偏移量,第二,在虛函數指針數組中索引相應的虛函數指針,最後纔是調用。但是,當基於對象引用調用虛函數時,只要經過一個間接層——就像在C++中一樣——直接在虛函數表中索引對應虛函數指針,接着調用。
關於基於接口的引用調用虛函數,還有一個細節就是,IOT裏爲每一個接口都準備了一個表項(包括該類並沒有實現的接口),原因是效率——.NET需要每個接口在IOT裏都有一個固定的(或者說,編譯期確定的)偏移量,這樣,在爲虛函數調用生成代碼的時候才能夠通過這個固定的偏移去查找某個接口的虛函數指針數組的所在。 另一方面,如果某個類的IOT僅僅包含它實現的接口,則經由接口引用去調用虛函數時,必須先知道該接口在IOT中的相應偏移,而這一信息必須通過運行期的動態查詢才能夠知道(因爲編譯器在手頭只有一個接口引用的情況下不可能知道它指向的是哪個類對象,從而也就不知道該類到底實現了哪些接口,所以要求助於運行期的動態查詢,而在前面所說的方式(也就是.NET所用的方式)下,編譯器不用知道接口引用到底指向哪個類對象,因爲在每個類的CORINFO_CLASS_STRUCT中的固定位置都有一個pIOT,指向一個IOT,其中每個接口都對應一個固定的(編譯器知道的)表項)——顯然,在每次調用虛函數之前都進行一次動態查詢是不可容忍的效率損傷,所以.NET寧可讓IOT多一些表項,以空間換時間。
或許你認爲這過於複雜,但是這是必須的,.NET中的基於接口的繼承對應於C++中的多重繼承,後者的實現也有類似的複雜性——或許更復雜一些。
最後,要說明的是,本文對於一個純粹的實用者或許顯得多餘,但是對於想把一門語言使用得更好的人卻是有用的。知其然而知其所以然,才能夠遊刃有餘。而其實現機理在實際運用中能起到拋磚引玉的作用也未可知。
C++ 中重載 + 操作符的正確方法
用戶定義的類型,如:字符串,日期,複數,聯合體以及文件常常重載二元 + 操作符以實現對象的連接,附加或合併機制。但是要正確實現 + 操作符會給設計,實現和性能帶來一定的挑戰。本文將概要性地介紹如何選擇正確的策略來爲用戶定義類型重載這個操作符。
考慮如下的表達式: int x=4+2;
內建的 + 操作符有兩個類型相同的操作數,相加並返回右值 6,然後被賦值給 x。我們可以斷定內建的 + 是一個二元的,對稱的,可交換的操作符。它產生的結果的類型與其操作數類型相同。按照這個規測,當你爲某個用戶定義類型重載操作符時,也應該遵循相應內建操作符的特徵。
爲用戶定義類型重載 + 操作符是很常見的編程任務。儘管 C++ 提供了幾種實現方法,但是它們容易使人產生設計上的誤解,這種誤解常常影響代碼的正確性,性能以及與標準庫組件之間的兼容性。
下面我們就來分析內建操作符的特徵並嘗試模仿其相應的重載機制。
第一步:在成員函數和非成員函數之間選擇
你可以用類成員函數的方式實現二元操作符如:+、- 以及 ==,例如:
class String
{
public:
bool operator==(const String & s); // 比較 *this 和 s
};
這個方法是有問題的。相對於其內建的操作符來說,重載的操作符在這裏不具有對稱性;它的兩個參數一個類型爲:const String * const(這個參數是隱含的),另一個類型爲:const String &。因此,一些 STL 算法和容器將無法正確處理這樣的對象。
另外一個可選方法是把重載操作符 + 定義爲一個外部(extern)函數,該函數帶兩個類型相同的參數:
String operator + (const String & s1, const String s2);
這樣一來,類 String 必須將該重載操作符聲明爲友元:
class String
{
public:
friend String operator+(const String& s1,const String&s2);
};
第二步:返回值的兩難選擇
如前所述,內建操作符 + 返回右值,其類型與操作數相同。但是在調用者堆棧裏返回一個對象效率很低,處理大型對象時尤其如此。那麼能不能返回一個指針或引用呢?答案是不行。因爲返回指針破壞參數類型與返回值類型應該相同的規則。更糟的是,鏈接多個表達式將成爲不可能:
String s1,s2,s3;
String res;
res=s1+s2+s3; // 不可能用 String* 作爲返回值
雖然有一個辦法可以定義額外的 + 操作符重載版本,但這個辦法是我們不希望用的,因爲返回的指針必須指向動態分配的對象。這樣的話,如果調用者釋放(delete)返回的指針失敗,那麼將導致內存泄漏。顯然,返回 String* 不是一個好主意。
那麼返回 String& 好不好呢?返回的引用必須一定要是一個有效的 String。它避免了使用動態對象分配,該方法返回的是一個本地靜態對象的引用。靜態對象確實解決了內存泄漏問題,但這個方法的可行性仍然值得懷疑。在一個多線程應用中,兩個線程可能會併發調用 + 操作符,因此造成 String 對象的混亂。而且,因爲靜態對象總是保留其調用前的狀態,所以有必要針對每次 + 操作符的調用都清除該靜態 String 對象。由此看來,在堆棧上返回結果仍然是最安全和最簡單的解決方案。
C++中用函數模板實現和優化抽象操作
本文介紹函數模板的概念、用途以及如何創建函數模板和函數模板的使用方法......
在創建完成抽象操作的函數時,如:拷貝,反轉和排序,你必須定義多個版本以便能處理每一種數據類型。以 max() 函數爲例,它返回兩個參數中的較大者:
double max(double first, double second);
complex max(complex first, complex second);
date max(date first, date second);
//..該函數的其它版本
儘管這個函數針對不同的數據類型其實現都是一樣的,但程序員必須爲每一種數據類型定義一個單獨的版本:
double max(double first, double second)
{
return first>second? first : second;
}
complex max(complex first, complex second)
{
return first>second? first : second;
}
date max(date first, date second)
{
return first>second? first : second;
}
這樣不但重複勞動,容易出錯,而且還帶來很大的維護和調試工作量。更糟的是,即使你在程序中不使用某個版本,其代碼仍然增加可執行文件的大小,大多數編譯器將不會從可執行文件中刪除未引用的函數。
用普通函數來實現抽象操作會迫使你定義多個函數實例,從而招致不小的維護工作和調試開銷。解決辦法是使用函數模板代替普通函數。
使用函數模板
函數模板解決了上述所有的問題。類型無關並且只在需要時自動實例化。本文下面將展示如何定義函數模板以便抽象通用操作,示範其使用方法並討論優化技術。
第一步:定義
函數模板的聲明是在關鍵字 template 後跟隨一個或多個模板在尖括弧內的參數和原型。與普通函數相對,它通常是在一個轉換單元裏聲明,而在另一個單元中定義,你可以在某個頭文件中定義模板。例如:
// file max.h
#ifndef MAX_INCLUDED
#define MAX_INCLUDED
template <class T> T max(T t1, T t2)
{
return (t1 > t2) ? t1 : t2;
}
#endif
<class T> 定義 T 作爲模板參數,或者是佔位符,當實例化 max()時,它將替代具體的數據類型。max 是函數名,t1和t2是其參數,返回值的類型爲 T。你可以像使用普通的函數那樣使用這個 max()。編譯器按照所使用的數據類型自動產生相應的模板特化,或者說是實例:
int n=10,m=16;
int highest = max(n,m); // 產生 int 版本
std::complex<double> c1, c2;
//.. 給 c1,c2 賦值
std::complex<double> higher=max(c1,c2); // complex 版本
第二步:改進設計
上述的 max() 的實現還有些土氣——參數t1和t2是用值來傳遞的。對於像 int,float 這樣的內建數據類型來說不是什麼問題。但是,對於像std::complex 和 std::sting這樣的用戶定義的數據類型來說,通過引用來傳遞參數會更有效。此外,因爲 max() 會認爲其參數是不會被改變的,我們應該將 t1和t2聲明爲 const (常量)。下面是 max() 的改進版本:
template <class T> T max(const T& t1, const T& t2)
{
return (t1 > t2) ? t1 : t2;
}
額外的性能問題
很幸運,標準模板庫或 STL 已經在 <algorithm> 裏定義了一個叫 std::max()的算法。因此,你不必重新發明。讓我們考慮更加現實的例子,即字節排序。衆所周知,TCP/IP 協議在傳輸多字節值時,要求使用 big endian 字節次序。因此,big endian 字節次序也被稱爲網絡字節次序(network byte order)。如果目的主機使用 little endian 次序,必須將所有過來的所字節值轉換成 little endian 次序。同樣,在通過 TCP/IP 傳輸多字節值之前,主機必須將它們轉換成網絡字節次序。你的 socket 庫聲明四個函數,它們負責主機字節次序和網絡字節次序之間的轉換:
unsigned int htonl (unsigned int hostlong);
unsigned short htons (unsigned short hostshort);
unsigned int ntohl (unsigned int netlong);
unsigned short ntohs (unsigned short netshort);
這些函數實現相同的操作:反轉多字節值的字節。其唯一的差別是方向性以及參數的大小。非常適合模板化。使用一個模板函數來替代這四個函數,我們可以定義一個聰明的模板,它會處理所有這四種情況以及更多種情形:
template <class T> T byte_reverse(T val);
爲了確定 T 實際的類型,我們使用 sizeof 操作符。此外,我們還使用 STL 的 std::reverse 算法來反轉值的字節:
template <class T> T byte_reverse(T val)
{
// 將 val 作爲字節流
unsigned char *p=reinterpret_cast<unsigned char*> (&val);
std::reverse(p, p+sizeof(val));
return val;
}
使用方法
byte_reverse() 模板處理完全適用於所有情況。而且,它還可以不必修改任何代碼而靈活地應用到其它原本(例如:64 位和128位)不支持的類型:
int main()
{
int n=1;
short k=1;
__int64 j=2, i;
int m=byte_reverse(n);// reverse int
int z=byte_reverse(k);// reverse short
k=byte_reverse(k); // un-reverse k
i=byte_reverse(j); // reverse __int64
}
注:模板使用不當會影響.exe 文件的大小,也就是常見的代碼浮腫問題
C++中用vectors改進內存的再分配
摘要:本文描述的是一種很常見的情況:當你在某個緩存中存儲數據時,常常需要在運行時調整該緩存的大小,以便能容納更多的數據。本文將討論如何使用 STL 的 vector 進行內存的再分配。
這裏描述的是一種很常見的情況:當你在某個緩存中存儲數據時,常常需要在運行時調整該緩存的大小,以便能容納更多的數據。傳統的內存再分配技術非常繁瑣,而且容易出錯:在 C 語言中,一般都是每次在需要擴充緩存的時候調用 realloc()。在 C++ 中情況更糟,你甚至無法在函數中爲 new 操作分配的數組重新申請內存。你不僅要自己做分配處理,而且還必須把原來緩存中的數據拷貝到新的目的緩存,然後釋放先前數組的緩存。本文將針對這個問題提供一個安全、簡易並且是自動化的 C++ 內存再分配技術——即使用 STL 的 vector。
用 STL vector 對象取代內建的數組來保存獲取的數據,既安全又簡單,並且是自動化的。
進一步的問題分析
在提出解決方案之前,我先給出一個具體的例子來說明 C++ 重新分配內存的弊病和複雜性。假設你有一個編目應用程序,它讀取用戶輸入的 ISBNs,然後將之插入一個數組,直到用戶輸入 0 爲止。如果用戶插入的數據多於數組的容量,那麼你必須相應地增加它的大小:
#include <iostream>
using namespace std;
int main()
{
int size=2; // 初始化數組大小;在運行時調整。
int *p = new int[size];
int isbn;
for(int n=0; ;++n)
{
cout<< "enter an ISBN; press 0 to stop ";
cin>>isbn;
if (isbn==0)
break;
if (n==size) // 數組是否到達上限?
reallocate(p, size);
p[n]=isbn; // 將元素插入擴容的數組
}
delete [] p; // 不要忘了這一步!
}
注意上述這個向數組插入數據的過程是多麼的繁瑣。每次反覆,循環都要檢查緩存是否達到上限。如果是,則程序調用用戶定義的函數 reallocate(),該函數實現如下:
#include <algorithm> // for std::copy
int reallocate(int* &p, int& size)
{
size*=2; // double the array''s size with each reallocation
int * temp = new int[size];
std::copy(p, p+(size/2), temp);
delete [] p; // release original, smaller buffer
p=temp; // reassign p to the newly allocated buffer
}
reallocate() 使用 STL std::copy() 算法對緩存進行合理的擴充——每次擴充都放大一倍。這種方法可以避免預先分配過多的內存,從量上減少需要重新分配的內存。這個技術需要得到充分的測試和調試,當初學者實現時尤其如此。此外,reallocate() 並不通用,它只能處理整型數組的情形。對於其它數據類型,它無能爲力,你必須定義該函數額外的版本或將它模板化。幸運的是,有一個更巧妙的辦法來實現。
創建和優化 vector
每一個 STL 容器都具備一個分配器(allocator),它是一個內建的內存管理器,能自動按需要重新分配容器的存儲空間。因此,上面的程序可以得到大大簡化,並擺脫 reallocator 函數。
第一步:創建 vector
用 vector 對象取代內建的數組來保存獲取的數據。main() 中的循環讀取 ISBN,檢查它是否爲 0,如果不爲 0 ,則通過調用 push_back() 成員函數將值插入
vector: #include <iostream>
#include <vector>
using namespace std;
int main()
{
vector <int> vi;
int isbn;
while(true)
{
cout << "enter an ISBN; press 0 to stop ";
cin >> isbn;
if (isbn==0)
break;
vi.push_back(isbn); // insert element into vector
}
}
在 vector 對象構造期間,它先分配一個由其實現定義的默認的緩存大小。一般 vector 分配的數據存儲初始空間是 64-256 存儲槽(slots)。當 vector 感覺存儲空間不夠時,它會自動重新分配更多的內存。實際上,只要你願意,你可以調用 push_back() 任何多次,甚至都不用知道一次又一次的分配是在哪裏發生的。
爲了存取 vector 元素,使用重載的 [] 操作符。下列循環在屏幕上顯示所有 vector 元素:
for (int n=0; n<vi.size(); ++n)
{
cout<<"ISBN: "<<vi[n]<<endl;
}
第二步:優化
在大多數情況下,你應該讓 vector 自動管理自己的內存,就像我們在上面程序中所做的那樣。但是,在注重時間的任務中,改寫默認的分配方案也是很有用的。假設我們預先知道 ISBNs 的數量至少有 2000。那麼就可以在對象構造期間指出容量,以便 vector 具有至少 2000 個元素的容量:
vector <int> vi(2000); // 初始容量爲 2000 個元素
除此之外,我們還可以調用 resize() 成員函數:
vi.resize(2000);// 建立不小於 2000 個元素的空間
這樣,便避免了中間的再分配,從而提高了效率。
深入探討C++中的引用
摘要:介紹C++引用的基本概念,通過詳細的應用分析與說明,對引用進行全面、透徹地闡述。
關鍵詞:引用,const,多態,指針
引用是C++引入的新語言特性,是C++常用的一個重要內容之一,正確、靈活地使用引用,可以使程序簡潔、高效。我在工作中發現,許多人使用它僅僅是想當然,在某些微妙的場合,很容易出錯,究其原由,大多因爲沒有搞清本源。故在本篇中我將對引用進行詳細討論,希望對大家更好地理解和使用引用起到拋磚引玉的作用。
引用簡介
引用就是某一變量(目標)的一個別名,對引用的操作與對變量直接操作完全一樣。
引用的聲明方法:類型標識符 &引用名=目標變量名;
【例1】:int a; int &ra=a; //定義引用ra,它是變量a的引用,即別名
說明:
(1)&在此不是求地址運算,而是起標識作用。
(2)類型標識符是指目標變量的類型。
(3)聲明引用時,必須同時對其進行初始化。
(4)引用聲明完畢後,相當於目標變量名有兩個名稱,即該目標原名稱和引用名,且不能再把該引用名作爲其他變量名的別名。
ra=1; 等價於 a=1;
(5)聲明一個引用,不是新定義了一個變量,它只表示該引用名是目標變量名的一個別名,它本身不是一種數據類型,因此引用本身不佔存儲單元,系統也不給引用分配存儲單元。故:對引用求地址,就是對目標變量求地址。&ra與&a相等。
(6)不能建立數組的引用。因爲數組是一個由若干個元素所組成的集合,所以無法建立一個數組的別名。
引用應用
1、引用作爲參數
引用的一個重要作用就是作爲函數的參數。以前的C語言中函數參數傳遞是值傳遞,如果有大塊數據作爲參數傳遞的時候,採用的方案往往是指針,因爲這樣可以避免將整塊數據全部壓棧,可以提高程序的效率。但是現在(C++中)又增加了一種同樣有效率的選擇(在某些特殊情況下又是必須的選擇),就是引用。
【例2】:
void swap(int &p1, int &p2) //此處函數的形參p1, p2都是引用
{ int p; p=p1; p1=p2; p2=p; }
爲在程序中調用該函數,則相應的主調函數的調用點處,直接以變量作爲實參進行調用即可,而不需要實參變量有任何的特殊要求。如:對應上面定義的swap函數,相應的主調函數可寫爲:
main( )
{
int a,b;
cin>>a>>b; //輸入a,b兩變量的值
swap(a,b); //直接以變量a和b作爲實參調用swap函數
cout<<a<< ' ' <<b; //輸出結果
}
上述程序運行時,如果輸入數據10 20並回車後,則輸出結果爲20 10。
由【例2】可看出:
(1)傳遞引用給函數與傳遞指針的效果是一樣的。這時,被調函數的形參就成爲原來主調函數中的實參變量或對象的一個別名來使用,所以在被調函數中對形參變量的操作就是對其相應的目標對象(在主調函數中)的操作。
(2)使用引用傳遞函數的參數,在內存中並沒有產生實參的副本,它是直接對實參操作;而使用一般變量傳遞函數的參數,當發生函數調用時,需要給形參分配存儲單元,形參變量是實參變量的副本;如果傳遞的是對象,還將調用拷貝構造函數。因此,當參數傳遞的數據較大時,用引用比用一般變量傳遞參數的效率和所佔空間都好。
(3)使用指針作爲函數的參數雖然也能達到與使用引用的效果,但是,在被調函數中同樣要給形參分配存儲單元,且需要重複使用"*指針變量名"的形式進行運算,這很容易產生錯誤且程序的閱讀性較差;另一方面,在主調函數的調用點處,必須用變量的地址作爲實參。而引用更容易使用,更清晰。
如果既要利用引用提高程序的效率,又要保護傳遞給函數的數據不在函數中被改變,就應使用常引用。
2、常引用
常引用聲明方式:const 類型標識符 &引用名=目標變量名;
用這種方式聲明的引用,不能通過引用對目標變量的值進行修改,從而使引用的目標成爲const,達到了引用的安全性。
【例3】:
int a ;
const int &ra=a;
ra=1; //錯誤
a=1; //正確
這不光是讓代碼更健壯,也有些其它方面的需要。
【例4】:假設有如下函數聲明:
string foo( );
void bar(string & s);
那麼下面的表達式將是非法的:
bar(foo( ));
bar("hello world");
原因在於foo( )和"hello world"串都會產生一個臨時對象,而在C++中,這些臨時對象都是const類型的。因此上面的表達式就是試圖將一個const類型的對象轉換爲非const類型,這是非法的。
引用型參數應該在能被定義爲const的情況下,儘量定義爲const 。
3、引用作爲返回值
要以引用返回函數值,則函數定義時要按以下格式:
類型標識符 &函數名(形參列表及類型說明)
{函數體}
說明:
(1)以引用返回函數值,定義函數時需要在函數名前加&
(2)用引用返回一個函數值的最大好處是,在內存中不產生被返回值的副本。
【例5】以下程序中定義了一個普通的函數fn1(它用返回值的方法返回函數值),另外一個函數fn2,它以引用的方法返回函數值。
#include <iostream.h>
float temp; //定義全局變量temp
float fn1(float r); //聲明函數fn1
float &fn2(float r); //聲明函數fn2
float fn1(float r) //定義函數fn1,它以返回值的方法返回函數值
{
temp=(float)(r*r*3.14);
return temp;
}
float &fn2(float r) //定義函數fn2,它以引用方式返回函數值
{
temp=(float)(r*r*3.14);
return temp;
}
void main() //主函數
{
float a=fn1(10.0); //第1種情況,系統生成要返回值的副本(即臨時變量)
float &b=fn1(10.0); //第2種情況,可能會出錯(不同 C++系統有不同規定)
//不能從被調函數中返回一個臨時變量或局部變量的引用
float c=fn2(10.0); //第3種情況,系統不生成返回值的副本
//可以從被調函數中返回一個全局變量的引用
float &d=fn2(10.0); //第4種情況,系統不生成返回值的副本
//可以從被調函數中返回一個全局變量的引用
cout<<a<<c<<d;
}
引用作爲返回值,必須遵守以下規則:
(1)不能返回局部變量的引用。這條可以參照Effective C++[1]的Item 31。主要原因是局部變量會在函數返回後被銷燬,因此被返回的引用就成爲了"無所指"的引用,程序會進入未知狀態。
(2)不能返回函數內部new分配的內存的引用。這條可以參照Effective C++[1]的Item 31。雖然不存在局部變量的被動銷燬問題,可對於這種情況(返回函數內部new分配內存的引用),又面臨其它尷尬局面。例如,被函數返回的引用只是作爲一個臨時變量出現,而沒有被賦予一個實際的變量,那麼這個引用所指向的空間(由new分配)就無法釋放,造成memory leak。
(3)可以返回類成員的引用,但最好是const。這條原則可以參照Effective C++[1]的Item 30。主要原因是當對象的屬性是與某種業務規則(business rule)相關聯的時候,其賦值常常與某些其它屬性或者對象的狀態有關,因此有必要將賦值操作封裝在一個業務規則當中。如果其它對象可以獲得該屬性的非常量引用(或指針),那麼對該屬性的單純賦值就會破壞業務規則的完整性。
(4)引用與一些操作符的重載:
流操作符<<和>>,這兩個操作符常常希望被連續使用,例如:cout << "hello" << endl; 因此這兩個操作符的返回值應該是一個仍然支持這兩個操作符的流引用。可選的其它方案包括:返回一個流對象和返回一個流對象指針。但是對於返回一個流對象,程序必須重新(拷貝)構造一個新的流對象,也就是說,連續的兩個<<操作符實際上是針對不同對象的!這無法讓人接受。對於返回一個流指針則不能連續使用<<操作符。因此,返回一個流對象引用是惟一選擇。這個唯一選擇很關鍵,它說明了引用的重要性以及無可替代性,也許這就是C++語言中引入引用這個概念的原因吧。 賦值操作符=。這個操作符象流操作符一樣,是可以連續使用的,例如:x = j = 10;或者(x=10)=100;賦值操作符的返回值必須是一個左值,以便可以被繼續賦值。因此引用成了這個操作符的惟一返回值選擇。
【例6】 測試用返回引用的函數值作爲賦值表達式的左值。
#include <iostream.h>
int &put(int n);
int vals[10];
int error=-1;
void main()
{
put(0)=10; //以put(0)函數值作爲左值,等價於vals[0]=10;
put(9)=20; //以put(9)函數值作爲左值,等價於vals[9]=10;
cout<<vals[0];
cout<<vals[9];
}
int &put(int n)
{
if (n>=0 && n<=9 ) return vals[n];
else { cout<<"subscript error"; return error; }
}
(5)在另外的一些操作符中,卻千萬不能返回引用:+-*/ 四則運算符。它們不能返回引用,Effective C++[1]的Item23詳細的討論了這個問題。主要原因是這四個操作符沒有side effect,因此,它們必須構造一個對象作爲返回值,可選的方案包括:返回一個對象、返回一個局部變量的引用,返回一個new分配的對象的引用、返回一個靜態對象引用。根據前面提到的引用作爲返回值的三個規則,第2、3兩個方案都被否決了。靜態對象的引用又因爲((a+b) == (c+d))會永遠爲true而導致錯誤。所以可選的只剩下返回一個對象了。
4、引用和多態
引用是除指針外另一個可以產生多態效果的手段。這意味着,一個基類的引用可以指向它的派生類實例。
【例7】:
class A;
class B:public A{……};
B b;
A &Ref = b; // 用派生類對象初始化基類對象的引用
Ref 只能用來訪問派生類對象中從基類繼承下來的成員,是基類引用指向派生類。如果A類中定義有虛函數,並且在B類中重寫了這個虛函數,就可以通過Ref產生多態效果。
引用總結
(1)在引用的使用中,單純給某個變量取個別名是毫無意義的,引用的目的主要用於在函數參數傳遞中,解決大塊數據或對象的傳遞效率和空間不如意的問題。
(2)用引用傳遞函數的參數,能保證參數傳遞中不產生副本,提高傳遞的效率,且通過const的使用,保證了引用傳遞的安全性。
(3)引用與指針的區別是,指針通過某個指針變量指向一個對象後,對它所指向的變量間接操作。程序中使用指針,程序的可讀性差;而引用本身就是目標變量的別名,對引用的操作就是對目標變量的操作。
(4)使用引用的時機。流操作符<<和>>、賦值操作符=的返回值、拷貝構造函數的參數、賦值操作符=的參數、其它情況都推薦使用引用。