置頂隨筆
#
2012年5月10日
#
原文來自:http://chenlq.net/vc11-bit-sweet-condition-variable-condition_variable-header-files.html
天啊,cppblog的編輯器能不能再爛一點?
有興趣的同學,去看原文吧,無語了 :(
條件變量,是C++11中為了簡化線程之間訪問某個共享資源而提出的。在這個應用場景中,這個共享資源往往表現為某種條件。例如在生產者-消費者模式中,我們往往需要判斷用于存放產品的容器是在什么狀態(條件)下。如果容器是空的,生產者線程可以繼續,而消費者線程則需要暫停,而如果容器已經滿了,生產者線程需要暫停,而消費者線程可以繼續。而在這個場景中,我們就可以用條件變量來協調兩個線程之間的動作。關于條件變量在生產者-消費者模式下的應用,我給出了一個例子。
條件變量在協調兩個線程之間的協作的時候非常有用,我們這里再補充一個例子。假設我們需要為政府設計一個程序來管理路燈。在這里,我們用一個線程來檢查是否到了觀燈的時間,而另外一個線程則負責在條件滿足后關閉路燈。
#include
#include // 時間工具
#include // 線程
#include // 條件變量
#include // 互斥
using namespace std;
using namespace std::chrono;
// 條件變量
condition_variable cond;
// 互斥
mutex m;
// 表示條件的共享資源
bool morning = false;
// 檢查是否到了關燈的時間
void check()
{
// 記錄開始時間
auto start = system_clock::now();
do
{
// 當前線程休眠1000毫秒
this_thread::sleep_for(milliseconds(1000));
cout<<"it is still night."<<endl;
} // 檢查是否已經到了關燈的時刻
// 這里用seconds(4)表示路燈持續4秒
while ( system_clock::now() < start + seconds(4));
// 到達關燈時間,鎖定互斥對象,
// 修改表示條件的共享數據morning
lock_guard lk(m);
cout<<"it is morning."<<endl;
morning = true;
// 用notify_one()通知另外的線程,條件已經發送變化
cond.notify_one();
}
/// 關燈線程
void turnoff()
{
// 鎖定互斥對象,訪問表示條件的共享資源morning
unique_lock lk(m);
// 構造一個循環,只要條件沒有滿足
// 就一直執行條件變量的wait()方法,讓當前線程等待
while(!morning)
{
cond.wait(lk);
}
// 條件滿足。執行關燈動作
cout<<"turn off the light."<<endl;
}
int main(int argc, char* argv[])
{
// 創建兩個線程,分別執行檢查和關燈的動作
thread c(check);
thread t(turnoff);
c.join();
t.join();
return 0;
}
從這個例子中,我們可以得到這樣一些使用條件變量的要點:
條件變量總是需要與一個表示條件的共享資源以及對這個共享資源進行訪問控制的互斥對象。這就是我們在程序的開始部分定義的morning,m和cond。
// 條件變量
condition_variable cond;
// 互斥
mutex m;
// 表示條件的共享資源
bool morning = false;
這三者幾乎總是相伴同時出現。
在一個線程中,我們需要在條件滿足的時候修改表示條件的共享資源的值,然后用條件變量的notify_one()或者notify_all()通知正在等待的線程。這就是
// 到達關燈時間,鎖定互斥對象,
// 修改表示條件的共享數據morning
lock_guard lk(m);
cout<<"it is morning."<<endl;
morning = true;
// 用notify_one()通知另外的線程,條件已經發送變化
cond.notify_one();
而在另外一個線程中,我們需要構造一個以共享資源為條件的無限循環,當條件無法滿足時,就用條件變量的wait()或者wait_until()等函數進行等待,直到條件得到滿足,循環結束。
// 鎖定互斥對象,訪問表示條件的共享資源morning
unique_lock lk(m);
// 構造一個循環,只要條件沒有滿足
// 就一直執行條件變量的wait()方法,讓當前線程等待
while(!morning)
{
cond.wait(lk);
}
總結起來,條件變量簡化了對表示條件的共享資源的訪問,也省去了對共享資源的頻繁的鎖操作,進一步提高了效率。
2012年5月1日
#
學習C++11正當時,C++11 FAQ中文版本幫你快速了解和學習C++11,從而快人一步,登上C++11這趟快速列車 >> http://chenlq.net/cpp11-faq-chs對此你有什么意見和建議呢?
C++11標準已經在2011年正式通過。而各大編譯器也即將實現對C++11的完全支持。C++11可以說是C++歷史上最大的一次變革,增加了大量的語法特性,標準庫也得到了極大的增強。C++11 FAQ中文版是我們學習和掌握這一新標準的快捷通道。
http://chenlq.net/cpp11-faq-chs
2011年11月12日
#
源文來自:
http://imcc.blogbus.com/logs/172675220.html
?
在新頒布的C++新標準C++11中,最令人激動人心的,我想不是auto關鍵字,也不是Lambda表達式,而是其中的對并行計算的支持——新的線程庫(thread)的加入。
多核心CPU的普及應用,C++的主要應用領域,服務器程序,高性能計算等等,都對并行計算提出了新的要求,而這次C++中全新添加的線程庫,就是
對這一趨勢的應對。現在,C++程序員可以輕松地編寫多線程的程序,而無需借助系統API或者是第三方程序庫的支持。線程庫的加入給C++帶來的變化,無
異于 194,翻身的程序員們把歌唱。
C++11中的線程庫,很大程度上直接來自boost這塊C++的試驗田,其基本架構和組件都完全相同,如果你是一個boost線程庫的使用者,那
么在C++11中,你會感覺到是回到了老家一樣,到處都是熟人。而如果你是一個完全的新手,也不要緊,C++11中的線程庫非常簡單,任何人都可以輕松上
手,我就是這樣,但是要深究,還得好好學習。
下面是一個簡單的例子,用到了線程庫中的線程(thread),互斥(mutex),條件變量(condition),來模擬一個演唱會的入場檢票的場景,另外,為了模擬觀眾,用到了C++11中的新的隨機數的產生,模擬一個正態分布的訪客人群。不說了,還是看代碼:
#include <iostream>
#include <queue>
#include <vector>
// 隨機數
#include <random>
// 這里,我使用了boost實現的線程庫,如果你的編譯器已經支持C++11,則使用<thread>是一樣的
#include <boost\thread.hpp>
#include <boost\thread\locks.hpp>
#include <boost\thread\condition.hpp>
using namespace std;
using namespace boost;
// 共享資源和互斥對象
mutex mtx;
bool finish = false;? // 表示觀眾到來是否結束
// 觀眾,主要是為了表示檢票過程中的檢票耗費時間
class viewer
{
public:
??? void check()
??? {
??? ??? // 線程等待
??????? posix_time::milliseconds worktime(400);?
??? ??? this_thread::sleep(worktime);???
??? }
??? void arrival(int t)
??? {
??? ??? posix_time::seconds arrtime(t);?
??? ??? this_thread::sleep(arrtime);???
??? }
};
// 檢票口
// 它有一個隊列,用于保存到來的觀眾,并且用一個線程來處理隊列中的觀眾
class gate
{
??? typedef boost::mutex::scoped_lock scoped_lock;
public:
??? gate():count(0),no(0){};
??? // 啟動線程
??? void start(int n)
??? {
??? ??? no = n;
??? ??? t = thread(&gate::check,this);
??? }
??? // 檢票
??? void check()
??? {
??? ??? // 無限循環,知道觀眾數為0且不會有新的觀眾到來
??? ??? while(true)
??? ??? {
??? ??? ??? viewer v;
??? ??? ??? {
??? ??? ??? ??? // 鎖定互斥對象,開始訪問對列
??? ??? ??? ??? scoped_lock lock(m);
??? ??? ??? ??? if(0==vque.size())? // 如果隊列為空
??? ??? ??? ??? {
??? ??? ??? ??? ??? {
??? ??? ??? ??? ??? // 判斷是否還會有新的觀眾到來,也即是表示到達的線程是否結束
??????????????????? scoped_lock finlk(mtx);
??? ??? ??? ??? ??? if(finish)
??? ??? ??? ??? ??? ??? return; // 如果已經結束,檢票也同樣結束
??? ??? ??? ??? ??? }
??? ??? ??? ??? ??? // 如果觀眾數為0,則等待新的觀眾的到來
??????????????????? while(0 == vque.size())
??? ??? ??? ??? ??? {???
??? ??? ??? ??? ??? ????? // 這里的wait()是條件變量的關鍵,它會先是否lock所鎖定的互斥對象m一定時間,
????????????????????????? // 然后再次鎖定,接著進行(0==vque.size())的判斷。如此往復,知道size不等于0,
????????????????????????? // 循環條件無法滿足而結束循環,這里表達的條件就是,只有size!=0,也就是隊列中有
????????????????????????? // 觀眾才繼續向下。
????????????????????????? cond.wait(lock);
??? ??? ??? ??? ??? }
??? ??? ??? ??? }
??? ??? ??? ??? // 從對列中獲得觀眾,對其進行檢票
??? ??? ??? ??? v = vque.front();
??? ??? ??? ??? vque.pop();
??? ??? ??? ??? cond.notify_one(); // 這里是通知添加觀眾的進程,表示隊列已經有空位置了,可以添加新的觀眾
??? ??? ??? }
??? ??? ??? v.check();
??? ??? ??? ++count;
??? ??? }
??? }
??? // 將觀眾添加到隊列
??? void add(viewer v)
??? {
??? ??? // 同樣運用條件變量,判斷隊列是否已經滿了
??????? // 只有在隊列尚未滿的情況下才向下繼續
??????? scoped_lock lock(m);
??? ??? while(vque.size() >= 15 )
??? ??? {
??? ??? ??? cond.wait(lock);
??? ??? }
??? ??? vque.push(v); // 將觀眾添加到隊列
??? ??? cond.notify_one();? // 通知檢票進程,新的觀眾進入隊列,這樣在size=0時等待的條件可以更新
??? }
??? int getcount()
??? {
??? ??? return count;
??? }
??? int getno()
??? {
??? ??? return no;
??? }
??? // 等待線程執行完畢返回
??? void join()
??? {
??? ??? t.join();
??? }
private:
??? thread t;
??? mutex m;
??? condition cond;
??? queue<viewer> vque;
??? int count;
??? int no;
};
// 一共有10個檢票口
vector<gate> vgates(10);
// 用隨機數模擬觀眾到達
void arrival()
{???
??? default_random_engine re{}; // 產生一個均值為31的正態分布的隨機數
??? normal_distribution<double> nd(31,8);
??? // 將隨機數引擎和分布綁定一個函數對象
??? auto norm = std::bind(nd, re);
??? // 保存隨機數的容器
??? vector<int> mn(64);
???
??? // 產生隨機數
??? for(int i = 0;i<700;++i)
??? ??? ++mn[round(norm())];
???
??? int secs = 100;
??? // 產生0到9的隨機數,表示觀眾隨機地到達某一個檢票口
??? uniform_int_distribution<int>? index{0,9};
??? ?
??? // 進入檢票口隊列
??? for(auto i:mn)
??? {
??? ??? cout<<i<<endl;
??? ??? for(auto vi = 1; vi <= i; ++vi)
??? ??? {
??? ??? ??? // 將觀眾添加到某個gate的隊列中
???????????? (vgates[index(re)]).add(viewer());
??? ??? ??? // 等待一段時間
??? ??? ??? int t = round(secs/(float)(i+1));
??? ??? ??? this_thread::sleep(
??? ??? ??? posix_time::milliseconds(t));
??? ??? }
??? }
??? // 觀眾已經全部到達,進入隊列
???? cout<<"finish"<<endl;
??? mtx.lock();
??? finish = true;
??? mtx.unlock();
??? //cout<<"unlock"<<endl;
}
int main()
{
??? int i = 1;
??? // 啟動檢票線程
??? for(gate& g:vgates)
??? {
??? ??? g.start(i);
??? ??? ++i;
??? }
??? // 啟動到達線程,看看,在C++11中新線程的創建就這么簡單
??? thread arr = thread(arrival);
??? // 等待線程結束
??? arr.join();
??? int total = 0;
??? // 等待檢票線程結束,并輸出處理的人數
??? for(gate& g:vgates)
??? {
??? ??? g.join();
??? ??? total += g.getcount();
??? ??? cout<<"gate "<<g.getno()
??? ??? ??? <<" processed "<<g.getcount()<<" viewers."<<endl;
??? }
??? cout<<"there are "<<total<<"viewers in total."<<endl;
??? return 0;
}
這就是一個線程庫的簡單應用,模擬了非常復雜的場景。
因為自己對多線程開發還不太熟悉,這個程序在某些特定條件下會產生了死鎖,還有待進一步完善
2011年9月20日
#
原文來自:
http://imcc.blogbus.com/logs/162618478.html
在C++11中,我們可以使用shared_ptr管理某個對象的所有權,負責對象的析構。然而在某些情況下,我們只是希望安全的訪問某個對象,而不想擁有這個對象的所有權,對這個的析構負責(有點像電視劇中的那些不負責任的男人哦,只是玩玩而已,不會負責)。在這種情況下,我們可以使用表示弱引用的weak_ptr。
weak_ptr可以由一個shared_ptr構建,表示這個weak_ptr擁有這個shared_ptr所指向的對象的訪問權,注意,這里僅僅是訪問權,它不會改變智能指針的引用計數,自然也就不會去析構這個對象。利用weak_ptr,我們就可以安全地訪問那些不具備所有權的對象。
一個現實中的例子就是學校的傳達室,傳達室擁有一本學生的名單,如果某個電話來了找某個學生,傳達室會根據花名冊去嘗試訪問這個學生,如果這個學生還在學校,就直接呼叫這個學生,如果已經離開了,這給這個學生留一個消息。在這里,花名冊上的學生可能還在學校(對象還存在),也可能已經離開學校(對象已經析構),我們都需要對其進行訪問,而weak_ptr就是用來訪問這種不確定是否存在的對象的。
2011年9月10日
#
原文來自
http://imcc.blogbus.com/
C++小品:吃火鍋與shared_ptr,指針,拷貝構造函數和delete
讀者Terry問到一個關于拷貝構造函數的問題,大家可以參考答Terry:拷貝構造函數,其中論述了拷貝構造函數的必要性,然而,任何事物都是具有兩面性的,有時候我們需要自己定義類的拷貝構造函數來完成類的拷貝,然后,有的時候,這種以拷貝一個對象來完成另外一個對象的創建是不合理的(也就是在現實世界中,這種對象沒有可復制性,例如,人民幣),是應該被禁止的。我們來舉一個吃火鍋的例子:
// 火鍋,可以從中取出其中燙的東西
class hotpot
{
public:
hotpot(string f) :
food(f)
{
}
string fetch()
{
return
food;
}
private:
string food;
};
// 吃火鍋用的碗,當然是每個人專用的
class bowl
{
public:
bowl(string o) :
owner(o)
{
}
void put(string food)
{
cout<<"put
"< }
private:
string owner;
};
// 吃火鍋的人
class
human
{
public:
// 名子和吃的火鍋
human(string n,shared_ptr ppot) :
name(n),pot(ppot)
{
pbowl = new bowl(name);
};
//
OK了,從火鍋中取出來放到自己的碗里
void fetch()
{
string food =
pot->fetch();
// 放到自己的碗里
coutput(food);
}
private:
string name;
shared_ptr pot;
bowl* pbowl;
};
int
main()
{
// 服務員端上來牛肉火鍋
shared_ptr fpot(new hotpot("beaf"));
//
terry入席
human terry("terry",fpot);
//
又來了一個姓陳的,這里用的是默認的拷貝構造函數來創建terry的副本
human chen = terry;
//
terry夾了一塊肉
terry.fetch();
// 陳先生也夾了一塊肉
chen.fetch();
return 0;
}
到這里,似乎看起來一切OK,然而從程序輸出中我們卻發現了問題:
terry put beaf into terry's bowl.
terry put beaf into terry's bowl.
O my god!明明是兩個人(terry和chen),但是好像卻只有一個人做了兩次,陳先生也把肉加到了terry的碗里。
這就是當類中有指針類型的數據成員時,使用默認的拷貝構造函數所帶來的問題,導致其中的某些指針成員沒有被合理地初始化,這別是當這些指針指向的是與這個對象(human)有所屬關系的資源(bowl),在這種時候,我們必須自己定義類的拷貝構造函數,完成指針成員的合理初始化。在human中添加一個拷貝構造函數
human(const human& h)
{
// 兩個人顯然不能同名,所以只好給個無名氏了
name =
"unknown";
// 使用不同的碗
// bowl和human有所屬關系,所以這里必須創建新的對象
pbowl = new
bowl(name);
// 不過可以吃同一個火鍋
// pot和human并沒有所屬關系,所以可以共享一個對象
pot =
h.pot;
};
添加拷貝構造構造函數之后,兩個人不會將東西放到同一個碗中了,自己取得東西不會放到別人的碗里:
terry put beaf into terry's bowl.
unknown put beaf into unknown's
bowl.
這樣修改好多了,至少兩個人不會用同一個碗了。然而,這樣還是有問題,我們無法給第二個人命名,他成了無名氏了,這就是類當中的那些沒有可復制性的數據成員(一個人的名字自然不可以復制給另外一個人,如果human中有個wife,那肯定要上演世界大戰了),拷貝構造函數就會產生這樣的問題。
實際上,對于這類不具備可復制性的對象,為了不引起混亂,其拷貝構造操作是應當被禁止的,新標準C++11就注意到了這個問題,提供了一個delete關鍵字來禁用某些可能存在的(即使你規定human不可復制,也無法阻止程序員在使用human時寫出human
chen =
terry這樣的不合理的代碼)默認的(類的拷貝構造函數是默認提供的,對于那些不具備可復制性的類來說,這簡直是畫蛇添足,好心辦了壞事情)不合理的操作,這樣,我們就不能使用拷貝
構造函數了:
// 禁用human的拷貝構造函數
human(const human& h) = delete;
經過這樣的定義,當我們在代碼中嘗試將一個對象復制給另外一個對象(會調用拷貝構造函數)時,編譯器就會出錯誤提示,提醒程序員:hi,這樣可不行,我是獨一無二的,不能夠被復制
human chen = terry;
編譯器給這樣的提示:
Noname1.cpp:41:2: error: deleted function 'human::human(const
human&)'
Noname1.cpp:59:15: error: used here
所以,總結起來,在使用拷貝構造函數時,有兩個需要注意的地方:
- 如果類當中有指向具有所屬關系的對象的指針時(human中的pbowl指向的是屬于human的bowl對象,每個human對象應該有專屬的bowl對象),我們必須自定義拷貝構造函數,為這個指針創建屬于自己的專屬對象。
- 如果這個類當中,有不具備可復制性的成員(例如name,rmb,wife等),為了防止對象被錯誤的復制(即使我們沒有定義拷貝構造函數,編譯器也會默認提供,真是多此一舉),我們必須用delete禁用拷貝構造函數,這樣才能保證對象不會被錯誤地復制。關于human的克隆技術,應當是被明令禁止(delete)的。
2011年7月23日
#
更多來自:http://imcc.blogbus.com
5.1.2 函數調用機制
在學習編寫函數之前,我們首先要了解函數的調用機制,學會如何調用一個已經存在的函數。世界上已經有很多函數,我們可以直接調用這些函數來完成日常任務。世界上已經有很多輪子,我們沒有必要再去發明更多同樣的輪子,只需要用好它們就可以了。在實際的開發中,可供調用的現有函數主要有編譯器提供的庫函數、Windows API及第三方提供的函數庫等。通過調用他人的函數,可以復用他人的開發成果,在其開發成果的基礎上,實現快速開發,如圖5-3所示。
有了別人提供的函數,就可以調用這些函數來完成自己的功能。兩個函數之間的關系是調用與被調用的關系,我們把調用其他函數的函數稱為主調函數,被其他函數調用的函數稱為被調函數。一個函數是主調函數還是被調函數并不是絕對的,要根據其所處的相對位置而定:如果一個函數內部有函數,則相對其內部的函數它就是主調函數;如果它的外部有函數,則相對其外部函數它就是被調函數。
圖5-3 天上掉下個函數庫
2011年7月22日
#
更多來自:http://imcc.blogbus.com
5.1.1 將程序裝到箱子中:函數的聲明和定義
提問:把大象裝到冰箱中需要幾步?
回答:需要三步。第一,打開冰箱;第二,把大象放進冰箱;第三,關上冰箱。
提問:那么,把一個程序放進箱子需要幾步?
回答:需要兩步。第一,聲明一個函數;第二,定義這個函數。
沒錯,把一個函數放進箱子比把大象放進冰箱還要簡單。當分析一段長的程序代碼時,往往會發現一些代碼所實現的功能相對比較獨立。我們將程序中這些相對比較獨立的功能代碼組織到一起,用函數對其進行封裝,也就是將一個較長的程序分放到各個函數箱子中。
要裝東西,先得準備好箱子。為了找到具體功能實現代碼的箱子,需要給箱子貼上標簽,這個標簽就是函數的聲明,如圖5-2所示。
圖5-2 聲明一個函數,為箱子貼上
2011年7月21日
#
更多來自:http://imcc.blogbus.com
? 把程序裝進箱子:用函數封裝程序功能
在完成豪華的工資統計程序之后,我們信心倍增,開始向C++世界的更深遠處探索。
現在,可以使用各種數據類型和程序流程控制結構來編寫完整的程序了。但是,隨著要處理的問題越來越復雜,程序的代碼也越來越復雜,主函數也越來越長了。這就像我們將所有東西都堆放到一個倉庫中,隨著東西越來越多,倉庫慢慢就被各種東西堆滿了,顯得雜亂無章,管理起來非常困難。面對一個雜亂無章的倉庫,聰明的倉庫管理員提供了一個很好的管理辦法:將東西分門別類地裝進箱子,然后有序地堆放各個箱子。
這個好方法也可以用到程序設計中,把程序裝進箱子,讓整個程序結構清晰。
5.1 函數就是一個大箱子
當要處理的問題越來越復雜,程序越來越龐大的時候,如果把這些程序代碼都放到主函數中,將使得整個主函數異常臃腫,這樣會給程序的維護帶來麻煩。同時,要讓一個主函數來完成所有的事情,幾乎是一個不可能完成的任務。在這種情況下,可以根據“分而治之”的原則,按照功能的不同將大的程序進行模塊劃分,具有相同功能的劃分到同一個模塊中,然后分別處理各個模塊。函數,則成為模塊劃分的基本單位,是對一個小型問題處理過程的一種抽象。這就像管理一個倉庫,總是將同類的東西放到同一個箱子中,然后通過管理這些箱子來管理整個倉庫。在具體的開發實踐中,我們先將相對獨立的、經常使用的功能抽象為函數,然后通過這些函數的組合來完成一個比較大的功能。舉一個簡單的例子:看書看得肚子餓了,我們要泡方便面吃。這其實是一個很復雜的過程,因為這一過程中我們先要洗鍋,然后燒水,水燒開后再泡面,吃完面后還要洗碗。如果把整個過程描述在主函數中,那么主函數會非常復雜,結構混亂。這時就可以使用函數來封裝整個過程中的一些小步驟,讓整個主函數簡化為對這些函數的調用,如圖5-1所示。
圖5-1 將程序封裝到箱子,分而治之
2011年7月20日
#
更多來自:
http://imcc.blogbus.com4.3.4 對循環進行控制:break與continue
// 大款的收支統計程序
int nTotal = 0;
int nInput = 0;
do
{
cout<< "請輸入你的收入或支出:";
cin>>nInput;
if( 1000< nInput ) // 毛毛雨啊,就不用統計了
continue;
nTotal += nInput;
}while( 0 != nInput );
在這個大款的收支統計程序中,nInput接收用戶輸入后判斷其值是否小于1 000,如果小于1 000,則執行continue關鍵字,跳過后面的加和語句“nTotal += nInput;”,而直接跳轉到對條件表達式“0 != nInput”的計算,判斷是否可以開始下一次循環。值得注意的是,在for循環中,執行continue后,控制條件變化的更改語句并沒有被跳過,仍然將被執行,然后再計算條件表達式,嘗試下一次循環。
雖然break和continue都是在某種條件下跳出循環,但是兩者有本質的差別:break是跳出整個循環,立刻結束循環語句的執行;而continue只跳出本次循環,繼續執行下一次循環。圖4-6展示了break和continue之間的區別。
![clip_image002[4] clip_image002[4]](http://images.cnblogs.com/cnblogs_com/chenliangqiao/201107/201107201144564521.gif)
圖4-6 break和continue之間的區別
2011年7月19日
#
The world is built on C++.
——
Herb Sutter
看得有趣、學得輕松
看圖也能學C++?!
沒錯,看圖也能學C++!
這本迷你書是《我的第一本C++書》的迷你版,它抽取了《我的第一本C++書》中的全部的精美插圖,并配上相應的解釋說明。它以圖文并茂的生動形式,向你講解那些所謂的高深的C++知識,讓你對那些抽象的C++知識有一個更加形象的理解,向你展示這個美麗而神秘的C++世界,讓你在有趣的看圖過程中,輕松地學到了C++知識。
看得有趣、學得輕松
免費下載