C++ 物件

喜欢嗑瓜子發表於2024-04-24

概述

C++的招牌能力之一,也是C++的核心特性沒有之一, 也是在 C 基礎擴充套件的最重要的能力,一切皆可封裝為物件,有三大主要特性,封裝、多型、繼承。

基礎

簡單理解,類就是使用者自定義的一種資料結構,封裝了資料和行為(函式)的組合。類中的資料稱為成員變數,函式稱為成員函式。類可以被看作是一種模板,可以用來建立具有相同屬性和行為的多個物件,類定義格式如下。

class ClassName {
    Access Specifiers:		// 訪問修飾符 public、private、
        type VarName;		// 成員變數
        type function(){}	// 成員函式
}

定義名稱為 Person的類,包含一個成員變數、成員函式

class Person {
public:
    string name;

    void say() {
        std::cout << "Im " << this->name << endl;
    }
}

上面示例,在類的內部使用了this指標訪問成員屬性name,也可以使用作用域解析運算子::,調整 say 函式程式碼如下,效果相同

    void say() {
        std::cout << "Im " << Person::name << endl;
    }

然後,以該類為模板建立物件實體

Person p;
p.name = "tom";
p.say();

和宣告普通變數類似,建立了物件p,同時修改了 name 屬性和呼叫 say 函式,訪問成員使用.點運算子。

透過指標方式訪問成員屬性

Person *ptr = &p;

// 兩種方式都可以
(*ptr).say();
ptr->say();

上面示例中,兩種方能等價,(*ptr).say()不能寫成*ptr.say();,因為點運算子.的優先順序高於*,這種寫法會將t.age看成一個指標,然後取它對應的值,會出現無法預料的結果,因為寫法很麻煩C 語言引入新的箭頭運算子->,在 C++中得到沿用,並擴大到物件屬性

許可權修飾符可控制成員的可見範圍,C++支援三種修飾符,分別是:

  • public,公開屬性,物件內部、外部都可以訪問和修改
  • private,私有屬性,預設屬性,僅物件內部可以訪問和修改
  • protected,受保護屬性,物件內部、子類可以訪問和修改

簡單示例

class Person {
public:
    string name;
private:
    int age;
protected:
    string address;
}

看起來類和結構體定義比較相似,的確兩者部分特性相同,但有本質區別,簡單總結如下

  • 兩者都是自定義複合資料結構,可組合資料和方法
  • 結構體的屬性預設是 public且不允許修改,類屬性預設是private並支援修改
  • 類支援繼承,結構體不支援繼承
  • 類支援建構函式、解構函式,結構體不支援
  • 類提供了封裝機制,可以隱藏內部實現,只暴露必要的介面;結構體通常用於儲存資料,不太注重封裝。
  • 類支援運算子過載,結構體不支援
  • 類支援模板類,結構體不支援

總結,類和結構體在C++中都用於定義自定義資料型別,但類更注重封裝、繼承和多型,而結構體更注重儲存資料。

建構函式

建構函式是一種特殊的成員函式,在建立物件時自動執行,主要用於初始化物件,從型別可以分為:無參建構函式、有參建構函式、複製建構函式。建構函式的定義也有所區別,首先函式名必須和類名稱相同,並且沒有返回值(注意void都不需要),不能手動呼叫,支援初始化列表,支援過載。

建構函式示例,分別定義了三種型別的建構函式

class Person {
public:
    string name;

    Person() {					// 無參建構函式
        this->name = "tom";		
    }
    Person(string name) {		// 有參建構函式
        this->name = name;
    }
    Person(Person const &p) {	// 複製建構函式
        this->name = p.name;
    }
}

建立物件,根據引數自動匹配對應的建構函式,並呼叫執行

Person p;			// 呼叫無參建構函式
Person p1("jerry");	// 呼叫有參建構函式
Person p2(p1);		// 呼叫複製建構函式

特別注意,呼叫無慘建構函式時候沒有小括號 (),和函式申明語法衝突了,兩者格式一樣產生二義性,編譯器無法識別是函式申明、還是呼叫建構函式。

C++規範所有的類有兩個預設建構函式,由系統隱式提供,分別是無慘建構函式、複製建構函式。預設的複製建構函式,自動複製所有的屬性到新物件上,使用淺複製,可以過載實現深複製。

class Person {
public:
    string name;
    Addr *p_addr;					// 增加一個指標型別成員變數

    Person(Person const &p) {		// 過載複製函式
        this->name = p.name;
        this->p_addr = new Addr(*p.p_addr);		// 深度複製,在堆申請記憶體並建立新物件,記得在解構函式中釋放堆記憶體
    }
}

如果過載了建構函式,系統有可能不再提供預設建構函式,遵循如下規則:

  • 過載有參建構函式,系統就不再提供預設無慘建構函式
  • 過載複製建構函式,系統就不再提供預設無慘建構函式、複製建構函式。

一般情況如果過載了建構函式,會顯示再新增一個無參構造,如下示例

class Person {
public:
    Person(string name) {		// 過載有參建構函式
        ...
    }
    Persion() = default;		// 使用C++11預設建構函式特性
}

C++支援多種方式呼叫建構函式,也就是多種不同的方式建立物件,Java程式設計師可能感到驚訝,分別有如下三種
括號法
和申明變數的格式一樣,申請記憶體,建立物件,呼叫對應建構函式初始化物件

Person p;				// 呼叫無參建構函式
Person p1("jerry");		// 呼叫有參建構函式
Person p2(p1);			// 呼叫複製建構函式

顯示法
有點類似函式呼叫,返回匿名物件

Person p = Person()				// 呼叫無參建構函式
Person p1 = Person("jerry");	// 呼叫有參建構函式
Person p2 = Person(p1);			// 呼叫複製建構函式

隱式法
最詭異的呼叫方式,看起來是給變數賦值。單引數的建構函式有個預設隱藏技能,型別轉換運算子,當等號右邊的型別恰好匹配構造的引數型別,就會呼叫對應建構函式。

Person p1 = "jerry";	// 呼叫有參建構函式
Person p2 = p1;			// 呼叫複製建構函式

上面示例中兩個語句都觸發型別轉換運算子,會呼叫對應的建構函式。特別是p2 = p1語句,看起來p2等於p1賦值語句,其實底層呼叫複製建構函式,兩者是完全獨立的物件。

這個技能看起來很酷,但在某些情況下容易產生困擾,C++提供了一個修飾符 explicit,被修飾的構造器關閉型別轉換運算子的功能

class Person {
public:
    string name;
    explicit Person(Person const &p) {		// 修飾, 關閉型別轉換
        this->name = p.name;
    }
}

隱式呼叫建立物件

// 建立物件
Person p;
Person p1 = p;		// 編譯失敗

簡單總結,使用哪種方式都可以,要統一風格。

呼叫方法 無參構造 有參構造 複製構造
括號法 有限支援 支援 支援
顯示法 支援 支援 支援
隱式法 不支援 有限支援 支援

使用new建立物件,任何建立物件的方式前都可以加new關鍵字,會改變物件儲存的位置,將儲存在堆記憶體中,建立過程:申請堆記憶體,建立物件,返回指標

Person *p = new Person("jerry");
if (p == NULL) {
    exit(-1);
}

p->say();
delete p;

示例中,建立的物件就儲存在堆記憶體,如果分配失敗則退出程式,程式最後釋放堆記憶體。

初始化列表,建構函式的特有技能,可在函式定義中增加增加初始化資訊,在函式執行前就完成物件屬性的初始化

class Person {
public:
    string name;

    explicit Person(const string &new_name) : name(new_name) {}
}

上面示例,函式定義中的: name(name)就是初始化列表,在函式執行前,使用實參new_name初始化物件的name屬性,函式邏輯為空,也完成了賦值初始化,當然可以在函式繼續修改。

初始化引數列表,還可以用於預設值初始化

class Person {
public:
    string name;

    Person() : name("tom") {}	// 建構函式
}

上面示例,在無參建構函式上增加了初始化列表,引數是固定的 tom,只要無參建構函式建立物件name屬性總是tom

解構函式

與建構函式相對應的解構函式,物件銷燬時自動執行,主要用於釋放資源,如堆記憶體、檔案描述符等,函式定義也有特定格式,函式名稱是類名前加波浪號、沒有返回值(注意void都不需要)、不能有引數、不支援過載

class Person {
public:
    string name;

    Person() : name(new_name) {}	// 建構函式

    ~Person() {		// 解構函式
        ...
    }
}

物件銷燬收會自動執行解構函式,如果物件在棧儲存則退出棧時候執行,如果在堆儲存則釋放記憶體時候執行。

函式傳參

物件也可以做為函式引數傳遞,注意:和結構體特性一樣是值傳遞,形參和實參是不相等,也就是說在傳遞過程中會發生物件複製,Java程式設計師可能又會感到驚訝。

定義了一個函式,形參是Person型別

void match_name(Person person) {
    if (person.name == "tom") {
        // ...
    }
}

呼叫該函式,注意:此時會觸發物件複製,建立的p和傳入的p是兩個獨立物件,底層是呼叫物件的複製建構函式實現。

Person p("tom");
metch_name(p);

函式的返回值如果是物件,也會觸發物件複製,如下示例

Addr match_name(Person person) {		// 返回值是addr物件
    if (person.name == "tom") {
        Addr *addr = person.p_addr;
        return *addr;
    }
}

呼叫該函式,也會觸發函式複製,返回的addr和接收addr是兩個獨立的物件,底層也呼叫 addr複製建構函式實現

Person p("tom");
Addr addr = match_name(p);

這種特性並不友好,大多是情況下都是希望傳遞物件自身,而不是複製後的新物件,可能是沿用了 C 語言的結構體特性,C++的物件是在結構體基礎擴充而來的。解決方案有兩種,分別是指標傳遞和引用傳遞,其實兩者本質一樣,都是地址傳遞,引用傳遞簡化了語法,更加推薦引用傳遞方式。下面是兩種傳遞方式示例。

使用指標傳遞

void match_name(Person const *person) {		
    if (person->name == "tom") {
        ...
    }
}

呼叫

Person p("tom");
Addr addr = match_name(&p);

使用引用傳遞,使用更加簡潔

void match_name(Person const &person) {
    if (person.name == "tom") {
        ...
    }
}

呼叫

Person p("tom");
Addr addr = match_name(p);

特別注意的是返回值,如果使用指標或引用返回物件型別,一定要確保指標不能指向區域性變數,區域性變數儲存在棧中,函式呼叫結束就隨之銷燬了,返回的地址就是野指標

Addr* match_name(Person const &person) {		
    return person.p_addr;				// ok

    // ok, 指向堆記憶體
    // return new Addr(*(person.p_addr));	

    // err, 指向區域性變數了,野指標
    // Addr addr(*(person.p_addr));
    // return &addr;						
}

this 指標

這是個比較特殊的成員變數,由系統預設提供,它是一個指標,總是指向當前的物件例項。和 Java 的 this、python 的 self 等功能類似

簡單示例

class Person {
public:
    string name;

    explicit Person(string const &name) : name(name) {}

    void say() {
        cout << "Im " << this->name << endl;
    }
};

建立兩個物件,並執行 say 函式

Person p1("tom");
p1.say();			// Im tom

Person p2("jerry");
p2.say();			// Im Jerry

相同的語句this->name讀取內容不一樣,因為this總是指向當前的物件

程式碼調整下,把類定義中的this->name調整為 name,然後看看效果

void say() {
    cout << "Im " << name << endl;
}

和上面的示例一樣,建立兩個物件,然後執行 say

Person p1("tom");
p1.say();			// Im tom

Person p2("jerry");
p2.say();			// Im Jerry

可以發現兩者完完全一樣。這個讀取變數優先順序有關係:區域性變數->物件變數->全域性變數,逐層向上查詢,如果是物件變數,系統會自動補齊this指標。

如果物件屬性和區域性變數同名時,又想訪問物件的成員變數,就可以使用this指標,精確控制讀取位置

class Person {
private:
    string name;
public:
    void setName(string const &name) {
        this->name = name;			// this指標
    }
};

如上示例,使用this指標,把區域性變數name賦值給物件變數name。也可以總顯示的使用this指標,程式碼指向更加清晰。

還有個重要作用,如果成員函式希望返回物件本身,就可以使用this

class Person {
private:
    string name;
    int age;
public:
    Person* setName(string const &name) {
        this->name = name;
        return this;
    }

    Person* setAge(int const &age) {
        this->age = age;
        return this;
    }
};

使用示例,有點類似 Java 常用的Build技能

Person p;
p1.setName("tom").setAge(10);

另外特別注意, 指標this是被 const修飾過的指標常量,也就是說不允許修改的指向位置,但是可以修改指向的值。宣告this的虛擬碼如下

Person * const this;

如下示例,如果修改this指向將編譯失敗

void setName(string const &name) {
    this = NULL:		// err 
}

const 修飾

const修飾的變數為只讀變數,也可以用於修飾類的多個位置,分別有不同的功能,逐一介紹

修飾成員屬性,可稱為常屬性,就算是 public 的成員屬性,只要被修飾都變成只讀,不可修改

class Person {
public:
    int age;
    string const name;		// 修飾
    void setName(string const &name) {
        this->name = name;	// err, 編譯失敗
    }
}

修飾成員函式,可稱為常函式,被修飾的成員函式,不允許修改物件自身的任何屬性。

class Person {
public:
    string name;
    void setName(string const &name) const {		// 修飾
        this->name = name;	// err, 編譯失敗
    }
}

以上示例,修飾後的成員函式不允許修改成員屬性,另外注意const的位置,是在函式小括號之後,這是個專用語法。

修飾成員函式,其本質是進一步修飾this指標,指向的值也不能修改了,修飾後的this宣告虛擬碼如下

const Person * const this;

所以被const修飾的成員函式,可以讀取任意屬性,但是不能修改任何屬性。

但是 C++有開又增加mutable修飾符,被修飾的成員屬性,在常函式中也允許修改,可以更精細的控制許可權。

class Person {
public:
    int age;
    mutable string name;

    void setName(string const &name) const {
        this->name = name;		// ok
    }
    void setAge(int const &age) const {
        this->age = age;		// err, 編譯失敗
    }
    void say() {
        cout << "Im " << this->name << endl;
    }
}

如上示例,name屬性被mutable修飾了,所以name屬性允許在常函式中修改;age屬性則不允許被修改。

修飾物件,可稱為常物件,被修飾後不允許修改物件的任何屬性,被mutable的除外

const Person p;		// const 修飾
p.name = "tom";		// ok
p.age = 10;			// err

另外注意,常物件只允許呼叫常函式,下面示例編譯失敗

p.setNmae("ok");		// ok, setNmae是常函式
p.say();				// err, 普通不允許呼叫

靜態成員

使用 static 關鍵字定義的是靜態屬性,靜態成員無論建立多少個類的物件,靜態成員都只有一個副本。

class Person {
private
    static string category;	// 靜態屬性
    string getCategory() {		// 靜態函式
        category;
    }
public:
    string name;
}

過載運算子

相關文章