C++自修入門實境秀、C++ Primer 5版研讀秀 82/ ~v12動態記憶體12.1. Dynamic Memory and Smart ...



改中文版作文:

因為智慧指標(smart pointer)p對use_factory來說是區域性的,它會在use_factory結束時被摧毀(§ 6.1.1 ,頁204)。當p被摧毀時,它的參考計數會遞減,並受檢查。在這個例子中,p是指向factory所回傳的動態記憶體區配置物件的唯一指標。8:00因為作為shared_ptr智慧指標的p即將消失,這個指標所指向的那個動態配置的物件(dynamically allocated object)也會隨之銷毀,而那個物件所佔用的記憶體亦將隨後釋放。

但是,若還有其他的shared_ptr仍舊指向著它,那麼那個物件所佔用的記憶體就不會跟著p的銷毀而被釋放:

shared_ptr<Foo> use_factory(T arg)

{

shared_ptr<Foo> p = factory(arg);

//使用p

return p; //因為回傳是用傳值的方式——也就是拷貝——所以我們回傳p的時候參考計數是會遞增的

}// p已然離開範疇了,然而p所指的記憶體位置卻並未被釋放

在這個版本中,use_factory中的return述句會回傳p的一個拷貝給其呼叫者(§6.3.2)。拷貝一個shared_ptr會遞增對那個物件的參考計數。而如今,p雖然被摧毀,但是p所指的記憶體卻仍有另外一個使用者指向著它——也就是那個在回傳時被拷貝出來的shared_ptr物件。而shared_ptr類別是會保證只要還有任何的shared_ptr依附在那個記憶體上,那個記憶體就不會被釋放。

也就是因為直到最後一個shared_ptr消失之前,shared_ptr所指向的記憶體都不會被自動釋放。因此我們在撰寫程式碼時,一定要顧慮到:當不再需要某個動態物件後,切記要把所有指向該物件的shared_ptr清除乾淨。若是忘了徹底清除用不到的shared_ptr,雖然應用程式仍能一如往常地執行,30:00沒有異狀,但卻會無謂地浪費掉本來可資利用的記憶體資源。在用完shared_ptr之後,會忘了清除乾淨,最有可能是發生在將 shared_ptr作一種容器的元素型別來使用。如一旦對該容器元素進行了重新排序,若不再需要使用到容器中所有的元素時,就應該即時把不再用到的元素確實進行容器的erase運算,以徹底清除那些用不到的shared_ptr的元素。

注意:如果你將shared_ptr放到一個容器中,且你接著只需要使用到其中的某些元素,而非全部的元素,那麼就請你務必要記得清除掉你不再需要用到的元素。

有些類別配置的記憶體資源,其生命長度乃是動態配置性的 1:04:00

在程式設計的實務上可能會因為下列三個目的之一,而使用到動態記憶體:(為什麼會用到動態配置記憶體的原因如下)

1. 當不知道將會用到多少物件時(即會用上的物件數是個未知數)

2. 無法確認所需物件的型別是什麼時

3. 如有在數個物件之間共享資料資源這樣的需求時

容器類別就是會為了第一個目的而使用到動態記憶體的一種實例;而我們會在後面的第15章看到因為第二個目的而用上動態記憶體的例子。在本節中,我們將會定義一個類別,它會使用動態記憶體來讓數個物件達到共用共享底層資料資源的目的。

目前為止,我們接觸過的類別,它們配置給物件的記憶體資源其存在的時間,是跟該物件一致的。舉例來說,每個vector都「各自擁有」它自己的元素。當我們拷貝一個vector時,原來的vector和其所拷貝出來的vector,二者的元素會是各自獨立的、互不連屬的:





改中文版作文:

2:54:40 6:16:34

為了對Blob類別物件實現(implement)這樣的資料共用,我們會給每個StrBlob類別物件配置一個shared_ptr來指向一個動態配置的vector。這個StrBlob的類別成員shared_ptr會追蹤有多少StrBlob物件共用一個vector,而且會在最後那個StrBlob被摧毀時,刪除那個vector,並釋放其所佔用的記憶體資源。

3:04:20由是領悟所謂的動態配置記憶體或動態記憶體(dynamic memory)還不如譯成自助配置記憶體、自行配置記憶體,或DIY配置記憶體。「dynamic」在中文語境中還不如翻成「自助」「自行」或「DIY」來得適合

我們一樣會指定我們的StrBlob類別對於vector提供了怎樣的運算。但現在,我們只會實作一部分的vector運算,而不是全部。我們也會稍微更動對vector元素存取的相關運算(諸如front與back這樣的成員函式):在我們定義的StrBlob類別中,如果有人企圖存取不存在的元素,那麼這些運算就會先行攔查,並丟出一個例外錯誤(throw an exception)。

除了上述的運算外,我們的StrBlob類別還會定義一個預設建構器,以及一個參數型別為initializer_list<string> ( §6.2.6,頁220)的建構器;這個建構器會接受一個大括號圍起的初始器串列作為它的引數。

class StrBlob {

public:

typedef std::vector<std::string>::size_type size_type;

StrBlob();

StrBlob(std::initializer_list<std::string> il);

size_type size() const { return data->size();}

bool empty() const { return data->empty();}

//新增和移除元素的成員函式

void push_back(const std::string &t) {data->push_back(t);}

void pop_back();

//存取元素的成員函式

std::strings front ();

std::string& back();

private:

std::shared_ptr<std::vector<std::string>> data;

//如果對data指向的vector以[i]下標運算無效,就會丟出msg這樣的例外錯誤

void check(size_type i, const std::string &msg) const;

在這個StrBlob類別中,我們實作了 size、empty與push_back這些成員函式。這些成員會將它們的工作透過data這個智慧指標轉派(委派)給它們指向的(底層的underlying)vector來運作。舉例來說,一個StrBlob上的size()會將data解參考(dereference)後、呼叫vector的size成員函式來進行實際的運算工作(即「data->size()」所做的工作);餘者以此類推。

StrBlob建構器

而我們定義的這個StrBlob類別,其建構器都應該要使用它的建構器初始器串列(constructor initializer list,§7.1.4,頁265)來初始化它的「data」這個資料成員,以便讓這個智慧指標data指向一個經過動態配置的vector。StrBlob的預設建構器會配置一個空的vector 來讓data這個智慧指標成員來指向它:

StrBlob::StrBlob() : data(make_shared<vector<string>>()){}

StrBlob::StrBlob(initializer_list<string> il):

data(make_shared<vector<string>>(il)){}

而接受一個initializer_list(初始器串列)作為引數的建構器,則會將其參數傳給make_shared,作為make_shared回傳的智慧指標所指向的vector用來初始化的串列(§ 2.2.1,頁43、97、98。)。 這個vector的建構器就會藉由拷貝這個被傳入串列的值來初始化其本身的元素。即所謂的串列初始化(list initialization)。就是用串列初始化來初始化這個make_shared所回傳的智慧指標指向的vector。





改中文版作文:

4:24:40 6:31:29 8:56:17

對於存取vector元素的成員函式要怎麼重新定義(Element Access Members)

在我們定義的StrBlob類別中,pop_back、front與back這三個成員函式都必須用到vector中的成員函式。這些運算都必須在試著存取一個元素之前,先行檢查該元素是否存在。因為有好幾個成員函式都需要用到這樣的檢查,我們就可以對我們的StrBlob類別另外獨立定義一個叫做check的私有(private)成員函式,來提供這些成員函式使用。這個check會檢查一個給定的索引值「i」,檢查i是否是在vector容器的範圍中。除了需要一個索引值作為其引數,check還需要一個string型別的引數,check會將之傳給例外處理器(exception handler),用來傳達程式究竟出了什麼錯:

void StrBlob::check(size_type i, const string &msg) const

{

if (i >= data->size())//以引數i為基準來檢查

throw out_of_range(msg);

}

pop_back和其他StrBlob用來存取元素的成員函式都會先調用這個check來檢查它們想要存取的索引值位置是否仍有元素存在。如果check沒有丟出錯誤,這些成員函式就會把它們的工作轉給底層的vector運算來進行操作:

string& StrBlob::front ()

{

//如果vector是空的,check就會丟出例外

check (0, "front on empty StrBlob");

return data->front();

}

string& StrBlob::back()

{

check(0, "back on empty StrBlob");

return data->back();

}

void StrBlob::pop_back()

{

check (0, "pop_back on empty StrBlob);

data->pop_back();

}

我們還應該對front與back這兩個成員函式,用const來加以重載(§ 7.3.2,頁276)。定義這樣的const常值或唯讀的成員函式就留給各位讀者來作練習了。6:38:20 5:7:50 忘了暫停5:48:60

拷貝、指定和摧毀StrBlob 6:38:50 8:59:05

就像我們自定義的Sales_data類別,這裡的StrBlob類別也會沿用預設版本的運算來進行它型別物件的拷貝、指定和摧毀(§7.1.5,頁267)。預設情況是,這些運算會拷貝、指定和摧毀StrBlob類別的資料成員。我們的 StrBlob只有一個資料成員——就是「data」,它是一個shared_ptr型別的智慧指標。因此,當我們對StrBlob物件進行拷貝、指定或摧毀,那麼它的這個「data」成員就會被拷貝、指定或摧毀。

如我們在前面所見,如果對一個shared_ptr型別的物件進行拷貝的話,就會遞增它的參考計數。而將一個shared_ptr指定給另外一個,則會遞增指定運算子(assignment operator,即「=」)右運算元的參考計數,並遞減左運算元的計數。至於摧毀一個shared_ptr則會遞減這個shared_ptr的參考計數。如果一個shared_ptr的參考計數降到零,shared_ptr所指的物件就會自動銷毀。因此,StrBlob 建構器所配置的vector,就會在參考到那個vector的最後一個StrBlob物件被摧毀時,自動摧毀。



改中文版作文:9:2:29

12.1.2不藉由智慧指標而直接來管理記憶體

C++本身定義了兩個運算子來配置(allocate)和釋放(free)動態記憶體。new運算子是用來配置記憶體的,而delete則會釋放由new所配置的記憶體。7:16:00 9:3:05

當我們了解了這兩個運算子到底是如何地運作之後,我們就更能明白:使用new和delete來管控記憶體,為什麼會比使用智慧指標來管控,更容易出錯得多。甚至,那些自行用new和delete來直接管控記憶體的類別,就不能像使用智慧指標來管控的那樣,直接利用預設的拷貝、指定或摧毀的定義,來對其類別或型別物件的資料成員(§7.1.5,頁267)進行拷貝、指定或摧毀。也就是因為這樣,使用智慧指標來管控記憶體的程式,就更加容易(likely to be easier to)撰寫與除錯了。7:31:30

警告:在還沒學到第13章的本事前,自訂的類別應該只有在使用智慧指標管控記憶體的情況下,才考慮去對動態記憶體進行配置。9:7:18

使用new來動態配置和初始化物件

因為在自由存放區(free store,自由存放的記憶體區,或「記憶體的自由存放區」)配置的物件是不具名的(unnamed),所以 new就無法為它配置出來的物件命名,在這種情況下,要調用(呼叫,具名才能呼叫啊)那個物件,new就只能藉由回傳一個指向它所配置出來物件的指標,來進行對該物件的操控:

int *pi = new int; // pi指向一個動態配置的,

//不具名的、未初始化的int

這個new運算式會在自由存放區建構一個型別為int的物件,並回傳對那個物件的一個普通指標。7:45:00

預設情況下,動態配置的物件是預設初始化的(§2.2.1,頁43),這表示動態配置的內建或複合型別的物件具有未定義的值;而類別型別的物件則是由它們的預設建構器來初始化的:

改中文版作文:

string *ps = new string; // 初始化為空 string

int *pi = new int; // pi指向一個未初始化的int

我們可以使用直接初始化(§3.2.1)去初始化一個動態配置的物件。我們可以使用傳統的建構方式(construction)——即使用小括弧(using parentheses);而在新標準底下,我們也可以使用串列初始化(即使用大括號):

int *pi = new int (1024); // pi所指的物件具有 1024 這個值

string *ps = new string(10, '9' ); // 解參考ps後,得到的是"9999999999"——10個9

//pv指向的是具有十個元素的vector,存放從0到9的值

vector<int> *pv = new vector<int>{0,1,2,3,4,5,6,7,8,9};

我們也可以值初始化(§3.3.1,頁98)一個動態配置的物件,方法是在其型別名稱後面接著一對空的小括弧:

string *ps1 = new string; // 預設初始化為空的 string

string *ps = new string () ; // 值初始化為空的 string

int *pi1 = new int; //預設初始化;*pi1是未定義

int *pi2 = new int () ; // 值初始化為 0 ; 解參考pi2(*pi2)得到的是0

對於定義了它們自己建構器(§7.1.4,頁262)的類別型別(例如string)而言,對其作值初始化的操作與預設初始化的結果並沒有什麼區別;不管對這樣的類別物件加不加上小括號,它們都是由它們類別的預設建構器來初始化的,加不加上小括號對它們初始化的結果並沒有任何影響。然而對內建型別來說,加不加上小括號的差別就很大了:一個經過值初始化的內建型別物件會有一個定義好的值,但若是藉由預設初始化的物件就是未定義的(undefined)。同樣地,如果一個類別是藉由合成預設建構器(synthesized default constructor,即編輯器拼湊成、湊合著用的建構器)來初始化其內建型別的資料成員,而沒有定義自己的建構器來初始化這些成員,那麼沒在這樣的類別主體中被初始化的資料成員就會有未初始化的值(§7.1.4,頁263。也就是:它們將會是未定義的(undefined))。9:14:50

養成好習慣(Best Practices):就像我們為什麼通常會將要用到的變數進行適當地初始化一樣,將要用到的動態配置物件執行初始化,當然也是一個好的編程習慣。

當我們在小括弧內提供一個初始器,我們可以用auto ( §2.5.2,頁68)來從這個初始器推出我們想要配置的物件型別。然而,因為這樣的推測是編譯器憑藉初始器的型別來完成的,因此當我們想要用auto來臆測建構物件初始化後回傳的型別,在小括弧內就只能提供獨一的初始器:

auto p1 = new auto(obj); // p1指向型別為obj的一個物件

//那個物件會用obj來初始化

auto p2 = new auto{a,b,c}; //錯誤:必須為初始器使用小括孤

p1的型別是一個指標,指向的是從obj自動推斷出來的型別。如果obj是一個int,那麼p1就是對int的指標(int*);如果obj是一個string,那麼p1就是一個對string型別的指標(string*),依此類推。而新配置的物件也會以obj的值來進行初始化。

動態配置的唯讀(const)物件 8:51:10

在C++中,使用new來配置const物件也是可以的:

//配置並初始化一個唯讀的(const)int

const int *pci = new const int (1024);

//配置一個經過預設初始化的、唯讀的(const)、空的string

const string *pcs = new const string;



改中文版作文:

9:24:20 9:32:25

就像其他的任何const,一個動態配置的const(唯讀)物件也必須被初始化。一個類別型別只要定義了預設建構器(§7.1.4),那麼它的const動態物件就可以被隱含地初始化。其他未定義預設建構器的型別的物件則必須明確加以初始化。因為所配置的物件是唯讀的(const),new所回傳的指標就會是對const(唯讀物件)的指標(§2.4.2)。11:50:00

記憶體耗盡9:35:48

即使當今的電腦大多都配備了很大的記憶體容量,但自由存放區(或堆積區、堆置區)記憶體的耗盡,仍舊是非常可能會發生的。一旦應用程式用盡了作業系統配給給它的所有記憶體,那麼進行new的運算就會失敗。在預設情況下,只要new沒辦法配置到所需的記憶體(儲存區),就會丟出型別為bad_alloc(§5.6.3頁197)的一個例外錯誤(§5.6,頁193)。然而我們也可以使用另一種new的寫法(語法)來避免new的運算在發生錯誤時丟出例外情形:

int *p1 = new int; // 如果配置失敗,new 會擲出 std::bad_alloc

int *p2 = new (nothrow) int; //如果配置失敗,new會回傳一個null指標

由於在後面§ 19.1.2(頁823)中會提到的原因,這個形式的new就被稱作placement new (放置型的 new)。一個placement new運算式(expression,表達式,即「語法」)讓我們可以傳入額外的引數給new。在這裡,我們傳入了一個名為nothrow的物件,這是由程式庫所定義的。當我們傳入nothrow給new,就等於告訴new 它決定不要發出例外情形。只要這種語法形式(form)的new無法配置到所需的記憶體(儲存區),它就會回傳一個null指標,而不會丟出例外情形。 bad_alloc和nothrow都是定義在new標頭檔中。

如何釋放動態記憶體9:54:00 11:55:02

為了避免記憶體耗盡,我們就必須在動態配置的物件使用完畢後,將其記憶體歸還給作業系統。可以透過一個delete運算式(表達式,delete expression)來歸還記憶體資源給系統。一個delete運算式接受一個指向我們想要釋放的物件的指標作為引數:

delete p; // p這個指標必須指向一個動態配置的物件或者是null

指標值和delete的關係

我們傳入給delete的指標一定要是指向動態配置的記憶體的指標,或者是一個null指標(§ 2.3.2 )。delete一個不是由 new配置出來的動態記憶體指標,或者對相同的指標值進行多過一次的delete,都將會是未定義的(沒有意義的)行為:

int i, *pi1 = &i, *pi2 = nullptr;

double *pd = new double(33), *pd2 = pd;

delete i; //錯誤:i不是一個指標,而是一個區域變數(local)

delete pi1; //未定義:pi1指向一個區域變數(local),而不是new出來的動態記憶體位置(物件),也不是null指標

delete pd; //ok

delete pd2; //未定義:pd2所指向的動態記憶體區已經被釋放了,其物件已然銷毀了

delete pi2; // ok: delete null指標永遠都是ok的(對null指標進行delete運算永遠是ok的) 10:9:00



頁461

改中文版作文:10:10:10 11:56:20

編譯器會發現「delete i;」是一個錯誤,因為它知道i並不是一個指標。但在pi1和pd2上執行delete會發生的錯誤就比較不明顯、比較不容易被編譯器發現:一般情形是,編譯器並沒有辦法去分辨一個指標到底是指向靜態配置的物件、或是動態配置的物件(亦即「是編譯器控管的,抑或是程式自行控管的」)。同樣地,編譯器也無法知道一個指標它指向的記憶體位址是否已經被釋放了。大多數的編譯器面對這樣的delete表達式(運算式,expressions)都無法辨析出它們的對錯,即使它們是錯的,也沒辦法在編譯期間就先行偵測出來。

雖然一個唯讀的(const)物件其值無法修改,但它本身卻是可以被摧毀的。就跟其他任何的動態物件一樣,要摧毀一個唯讀的(const)動態物件、釋放它所佔用的記憶體,也須藉由在指向它的指標上執行delete 表達式的運算來進行這樣的操作:

const int *pci = new const int(1024);

delete pci; //ok:刪除一個 const物件

動態配置的物件會持續存在,直到它們被釋放為止

如我們在前面§12.1.1(頁452)見識到的,若是透過智慧指標shared_ptr來控管記憶體的話,那麼那個記憶體位置上的動態配置物件就會在最後一個指向它的 shared_ptr摧毀時同時被刪除。然而若僅僅使用內建型別的指標來管理記憶體的話,就沒有這樣的優勢了。因為透過一個內建指標來管理的動態物件,會一直存在著,佔住該記憶體的位置,直到它被明確刪除delete為止。

因此,若有一個函式其回傳值是一個指向動態配置物件的普通指標(而非智慧指標)的話,那麼呼叫它的使用者,就必須負責刪除這個函式所配置出來的動態物件,以釋放其記憶體資源: 10:42:30

// factory回傳一個普通指標指向一個經由new而動態配置出來的物件

Foo* factory(T arg)

{

//適當地處理arg

return new Foo(arg); //呼叫factory函式者必須負責刪除這個動態記憶體區配置的物件,以釋放其記憶體資源

}

就像我們前面的factory函式(§ 12.1.1,頁453),這個版本的factory會用new來動態配置一個物件,但並沒有用上delete表達式,也就是在factory執行期間並不會刪除它。因此呼叫factory函式的人或者程式碼就必須在它不再有用時負責清除掉它,並釋放它所佔用的記憶體資源。可是諷刺的是,常見的情形卻是,呼叫這樣函式的人或者程式碼往往都會忘了這麼做:

void use_factory(T arg)

{

Foo *p = factory(arg);

//使用了p,卻未刪除它

}// p離開範疇了,但p所指向的記憶體位置卻沒有得到釋放!

像這樣,我們的use_factory函式呼叫了factory,而factory會用new來動態配置一個型別為Foo的新物件。當 use_factory執行結束後,它的區域變數p會被摧毀,但這個p僅只是一個內建型別的指標,也就是普通指標(plain pointer、ordinary pointer),並不是一個智慧指標,所以即使指向它所指的物件的所有指標都銷毀了,但那個物件並不會隨之銷毀,而是會繼續存在,佔用著當初配置給它的記憶體資源。

不同於類別型別,內建型別的物件被摧毀時,什麼都不會發生。(大概指內建型別不具備解構器)特別是,當一個指標離開了它生命週期的範疇,它消失了,但它所指向的物件,卻什麼事也不會跟著發生。指標與所指之間,並沒有連動的關係,在這種情況下,它倆是互不影響的。因此,如果那個指標指向的是一個動態記憶體的位址,那麼那個記憶體位址就不會因其消亡而得到釋放。

警告:如果沒有透過智慧指標,而是透過內建型別的指標或普通指標來控管動態記憶體的話,那麼在明確釋放其記憶體位址前,那個佔住記憶體位址的物件都會持續地存在。



頁450

目前所學的記憶體區域有以下3種:

靜態static

堆疊stack 翻成堆棧較佳!第82集 2:27:00

集區pool



除了上述兩種記憶體區域(靜態與堆棧記憶體)外,作業系統對每個應用程式(program)還都配有一個叫做集區(pool,在這裡應該有點蓄水池的意思)的記憶體可資使用 。這個記憶體區域也叫作自由存放區(free store)或heap(堆積記憶體區or堆置記憶體區) 。第82集11:00:00 忘了暫停 應用程式則會把這個系統配置給它的堆積區域(heap)供給它所需要用到的動態配置(dynamically allocate)物件來使用。這種物件是程式在執行期間(run time)所動態進行配置的物件,因此程式本身就有責任去好好管控這些動態物件的生命長度,所以我們在撰寫這樣的程式它的程式碼的時候,就必須在不再需要這種物件的時候明確下達摧毀它們的指令,以釋放相關的記憶體。



集區(pool),翻成「匯總(區)」或「匯集(區)」也不錯!

heap,翻成「堆砌、堆垛、堆貯、堆集、堆置、堆積、纍堆」都可適用。個人以為「堆積」與「堆置」最好,因為「積」有積到「滿」,即記憶體耗盡的問題。而「置」則有「閒置」不用,忘了刪除物件,導致記憶體虛耗、浪費(本書翻成洩漏,leak)。而耗盡問題還不是本書常著墨的重點,可見「堆置」是更適切的譯語,也突顯了本節強調忘了刪除不要的動態配置的物件而引發的課題。矧「置」也與「配置」相應!



頁454

這就是所謂的記憶體洩漏(leak, 外漏) 第82集 1:06:00,就類似於shared_ptr殘留:

Because memory is not freed until the last shared_ptr goes away, it can be important to be sure that shared_ptrs don’t stay around after they are no longer needed. The program will execute correctly but may waste memory



練習12.3

第82集 6:54:00

const版本要在常值const的物件上才會被調用,既然是唯讀const的,如何push_back()、pop_back()。就好像前面Screen的例子,set只能在非常值nonconst上調用一樣。只有在不需要更動原物件下,才會考慮用const版本:

邏輯上來說,顯示(display)一個Screen並不會改變該物件,所以我們應該讓display成為一個const成員。(Returning *this from a const Member Function)

因為「front、back」只是讀取元素,並不會編輯或更動元素,所以應該定義為常值的成員函式。不會更動到需要調用的資料,就應當作為常值的來處理。在C++中,常值const就是所謂的唯讀readonly(在C#裡頭是有這樣的指令)。

練習12.4

因為 i的型別是size_type 必然是大於等於0的整數

練習12.5

第82集7:00:00

練習12.5 :我們並沒有讓接受一個initializer_list引數的建構器成為explicit(§ 7.5.4)。討論採取這樣設計的優缺點。

複習前面7.5.3. The Role of the Default Constructor

詳實境秀講解

利(優點):大概就是可以進行隱含轉型,不必具名,就可以直接用initializer_list引數來建構一個StrBlob物件來進行操作運算,少寫一些程式碼的

在一個用到StrBlob物件的地方,可以只提供這個引數即可

弊(缺點):

然而這種隱含轉型,可能不適用於動態配置物件上。且我們需要的是動態配置的vector物件,並不是一個初始器串列(initializer_list)類別。我們只是用這個初始器串列來初始化我們動態配置的vector物件的元素值。

留言

熱門文章