1. 程式人生 > 其它 >C++核心程式設計-面向物件

C++核心程式設計-面向物件

技術標籤:C++

cpp核心程式設計-面向物件

本文是b站黑馬程式設計師的課後筆記,講的真不錯,建議去聽聽b站地址

1.記憶體分割槽模型

cpp程式在執行時,將記憶體分為4個區域

  • 程式碼區:存放函式體的二進位制程式碼,由作業系統進行管理
  • 全域性區:存放全域性變數和靜態變數以及常量
  • 棧區:由編譯器自動分配釋放,存放函式的引數值,區域性變數等等
  • 堆區:由程式設計師分配和釋放,若程式設計師不釋放(俗稱記憶體洩露),程式結束時由作業系統回收

存在的意義:

不同區域存放的資料,賦予不同的生命週期,如此程式設計更加有條理和靈活。

1.1 程式執行前

在程式編譯後,會生成可執行程式,未執行程式前分為兩個區域:

程式碼區

  • 存放CPU執行的機器指令
  • 程式碼區是共享的,共享的目的是對於頻繁被執行的程式,只需要在記憶體中有一份程式碼即可
  • 程式碼區是只讀的,防止程式意外地修改了它的指令

全域性區

  • 全域性變數(global或在函式體外面宣告)和靜態變數static存放在此
  • 全域性區還包含了常量區,字串常量"abcdefg"和其他常量也存放在此
    • 其他常量僅僅包括const修飾的全域性變數const global
    • const修飾的區域性變數在區域性區域const local
  • 該區域的資料在程式結束後由作業系統釋放

1.2 程式執行後

棧區

  • 由編譯器自動分配釋放,如函式的引數值,區域性變數等
  • 注意:不要返回區域性變數的地址
    ,因為當函式體結束後,對應棧空間上中的記憶體會被釋放

堆區

  • 由程式設計師分配釋放,若程式設計師忘了釋放(俗稱記憶體洩露),程式結束時由作業系統回收
  • 在cpp中主要利用new在堆區中開闢記憶體
int * func()
{
int *p = new int(10);// new返回的是地址
return p;
}
int main(){
    int *p = func();
    cout << *p << endl;
    // 釋放記憶體
    delete p;
}

//陣列的堆空間開闢與釋放
int *arr = new int[10];
delete[] arr;  // delete需要加個[]

如上func()返回的地址為堆區的地址,不會隨著函式體生命結束而結束。

2.引用

引用的作用:給變數取個別名

原理:相當於宣告一個新變數,該變數與原變數共享同一個地址

語法資料型別 &別名 = 原名

int main(){
int a = 100;
int &b = a;
cout<<b<<endl;  // 100
a = 10;
cout<<b<<endl;  // 10
    int c = 99;
    b = c;
    cout<<a<<endl;  // 99
}

注意事項:

  • 引用必須要初始化,必須告訴它是誰的別名
  • 引用一旦初始化後,就不可更改

2.1 引用做函式引數

方式

void swap(int &a, int &b){
int temp = a;
a = b;
b = temp;
}

作用:利用引用的技術,形參將修飾實參,能改變傳入變數的值。稱作:引用傳遞

優點:可以簡化指標修改實參(地址傳遞)

三種傳遞形參的方式:

  • 值傳遞(int a)
  • 引用傳遞(int &a)
  • 地址傳遞(int *a)

2.2 引用做函式的返回值

方式:

int& test(){
static int a = 20;
return a;
}
// 如果函式作為左值,那麼必須返回引用
int main(){
    int &ref = test();
    test() = 10000;
    cout << ref << endl;
}

注意事項:不要返回區域性變數的引用

2.3 引用的本質

本質:引用的本質在cpp內部實現是一個指標常量

int a = 10;
// cpp內部的實現,等價於int *const ref = &a;指標常量指向不可改
int &ref = a;
ref = 20;  // 等價於*ref = 20

2.4 常量引用

作用:主要用來修飾形參,防止誤操作。在函式形參列表中,加const修飾形參,防止形參改變實參

int a = 10;
const int &ref = a;  //  const int *ref = &a;

其實就相當於在指標常量前加了個const,使得ref變成了修飾常量,引用的指向和指向的值都不可以修改。

3.函式提高

3.1 預設引數

需要注意的有兩點:

  • 一旦某個變數使用了預設引數,則其右邊的引數也應該全是預設引數形式
int add(int a, int b=10, int c=10){...}
  • 如果函式宣告有預設引數,函式實現就不能有預設引數
int func(int a=10, b=20);
int func(int a, int b){
return a+b;
}

3.2 函式的佔位引數

使用方法:

返回值型別 函式名(資料型別){}

注意:呼叫函式時必須填補該位置

void func(int a, int){
cout << "funnnnc" << endl;
}
int main(){
    func(10,10);
}

佔位引數還可以有預設引數

3.3 函式過載

作用:函式名可以相同,提高複用性

函式過載滿足條件:

  • 同一個作用域下
  • 函式名稱相同
  • 函式引數型別不同或者個數不同或者順序不同

注意:函式返回值不能作為過載條件

注意事項:

  1. 引用作為函式過載的條件
void func(int &a){
    cout <<func(int &a)<<endl;
}
void func(const int &a){
    cout <<func(const int &a)<<endl;
}
int main(){
    int a = 10;
    func(a);  // 滴啊用的是func(int &a)
    func(10); // 呼叫的是func(const int &a),因為func(const int &a)可以傳值,而func(int &a)不可以
}
  1. 函式過載碰到預設引數

當函式過載碰到預設引數,會發生二義性

void func(int a){
    cout <<func(int a)<<endl;
}
void func(int a, int b=10){
    cout <<func(int a, int b=10)<<endl;
}

以上函式過載會發生二義性,導致編譯器不知道呼叫哪一個

4.類和物件

萬事萬物皆為物件,物件的三大特性:封裝、繼承、多型

4.1 封裝

4.1.1 封裝的意義

封裝意義之一:

可以把屬性和行為寫在一起,表現事物

封裝意義之二:

在類設計時,可以把屬性和行為放在不同的許可權下,加以控制

訪問許可權有三種:

  • public:成員 類內可以訪問,類外可以訪問
  • protected :成員 類內可以訪問,類外不可以訪問;兒子可以訪問父親的protected內容
  • private:成員 類內可以訪問,類外不可以訪問;兒子不可以訪問父親的private內容

4.1.2 struct和class的區別

cpp中classstruct的核心區別在於

預設的訪問許可權不同

  • struct預設許可權為公共
  • class預設許可權為私有

其他區別有:

  • struct不能用於宣告類模板,而class可以
  • class是引用型別,struct是值型別。值型別在傳遞和賦值時將進行復制,而引用型別則只會使用引用物件的一個"指向"

所以,struct適合作為一種資料結構的集合,而class適合作為一種物件。

4.1.3 成員屬性設定為私有

  1. 將所有成員屬性設定為私有,可以自己控制讀寫許可權
  2. 對於寫許可權,我們可以檢測資料的有效性
#include<string>
class Person(
public:
    // 優點1
    void setAge(int age){
        // 優點2
        if (age<0 || age >120){
            cout << "Invalid age setting!"<<endl;
            return;
        }
        _age = age;
    }
    // 優點1
    int getAge(){
        return _age;
    }
private:
    int _age;
)

在對屬性的讀寫中,相當於多了一個介面,在這個介面中,我們可以有很多行為

4.2物件的初始化和清理

4.2.1建構函式與解構函式

存在的意義:

一個物件或者變數沒有初始狀態,其後果是未知的;若使用完一個物件或變數,沒有及時清理,也會造成一定的安全問題

實現:

cpp利用了建構函式和解構函式解決上述問題,這兩個函式會被編譯器自動呼叫,完成物件的初始化和清理工作。如果我們不提供構造和析構,編譯器會提供,編譯器提供的建構函式和解構函式是空實現

  • 建構函式:主要作用在於建立物件時為物件的成員屬性賦值,建構函式由編譯器自動呼叫,無須手動呼叫
在建立物件的時候,編譯器會首先給建構函式分配記憶體空間,然後再呼叫建構函式進行物件建立
  • 解構函式:主要作用在於物件銷燬前系統自動呼叫,執行一些清理工作
class Person{
public:
    Person(){
        cout << "Creating!" << endl;
    }
    ~Person(){
        cout<< "Done!" << endl;
    }
};
int main(){
    Person *pointer = new Person();  // 建立物件前,呼叫建構函式
    delete pointer;  // 銷燬前,呼叫解構函式
    cout << "Hello, world!" << endl;
}

4.2.2 建構函式的分類及呼叫

兩種分類方式:

  • 按引數分為:有參構造和無參構造
  • 按型別分為:普通構造和拷貝構造(Person(const Person &p))

三種呼叫方式:

  • 括號法Person p(10)
  • 顯示法Person p = Person(10)
  • 隱式轉換法Person p = 10
class Person{
public:
    // 拷貝構造
    Person(const Person &p){
        _age = p._age;
        cout << "Creating!" << endl;
    }
    ~Person(){
        cout<< "Done!" << endl;
    }
private:
    int _age;
};

有趣的是,p._age(private)能夠在同類,不同記憶體空間中進行呼叫

4.2.3 拷貝函式呼叫時機

cpp中拷貝建構函式呼叫時機通常有三種情況

  • 使用一個已經建立完畢的物件來初始化一個新物件
  • 值傳遞的方式給函式引數傳值
  • 以值方式返回區域性物件

使用一個已經建立完畢的物件來初始化一個新物件:

Person p1;
Person p2(p1);

值傳遞的方式給函式引數傳值:

int doSomething(Person p){
return p._age;
}

以值方式返回區域性物件:

Person makePerson(){
Person p
return p;
}
int main(){
    Person p = makePerson();
}

4.2.4建構函式呼叫規則

預設情況下,cpp編譯器至少給一個類新增3個函式

  1. 預設建構函式(無參,函式體為空)
  2. 預設解構函式(無參,函式體為空)
  3. 預設拷貝建構函式,對屬性進行值拷貝

建構函式呼叫規則如下:

  • 如果使用者定義有參建構函式,cpp不再提供無參構造,但是會提供預設拷貝構造
  • 如果使用者定義拷貝建構函式,cpp不會再提供其他建構函式

4.2.5深拷貝與淺拷貝

深拷貝和淺拷貝是面試經典問題,也是常見的一個坑

淺拷貝:簡單的賦值拷貝操作

深拷貝:在堆區重新申請空間,進行拷貝操作

問題

首先,我們在建立類的時候,可能會開闢一個堆空間進行變數儲存;而解構函式的作用通常就是,釋放這些在堆空間建立的變數記憶體,如下所示:

// 錯誤做法
class Person{
public:
    Person(int age, double height){
        _age = age;
        _height = new double(height);
        cout << "Person created!"<< endl;
    };
    ~Person(){
        delete _height;
    };
    int _age;
    double *_height;
};
int main(){
    Person p1(10, 160);
    Person p2(p1);
}

由於預設的拷貝建構函式是淺拷貝,即進行值傳遞,所以在執行Person p2(p1);後,p1p2*height將會指向同一個記憶體空間。所以在函式main()結束時,p2的解構函式會率先釋放記憶體,如此變回導致p1_height指向消失,程式報錯!!所以正確的做法應當如下:

class Person{
public:
    Person(int age, double height){
        _age = age;
        _height = new double(height);
        cout << "Person created!"<< endl;
    };
    Person(const Person& other){
        _age = other._age;
        _height = new double(*(other._height));  // 深拷貝
        cout << "Person created!"<< endl;
    };
    ~Person(){
        delete _height;
    };
    int _age;
    double *_height;
};
int main(){
    Person p1(10, 160);
    Person p2(p1);
}

在呼叫拷貝建構函式時,為_height在堆空間建立s新的記憶體,如此兩個物件的變數就不會指向同一個記憶體空間了。

4.2.6 初始化列表

作用:cpp提供了初始化列表語法,用來初始化屬性

語法建構函式():屬性1(值1),屬性2(值2)...{}

class Person{
public:
    Person(int age, double height):_age(age),_height(height){
        cout << "Person created!"<< endl;
    };
    int _age;
    double height;
}

4.2.7 類物件作為類成員

就是指類中的成員可以是另一個類的物件,我們稱該成員為物件成員。

所以在初始化的時候,一般先初始化成員變數,再初始化自身;

析構的時候,先析構自身,再析構成員變數

4.2.8 靜態成員

靜態成員就是在成員變數和成員函式前加上關鍵字static,稱為靜態成員

靜態成員分為:

  • 靜態成員變數

    • 所有物件共享一份資料
    • 在編譯階段分配記憶體
    • 類內宣告,類外初始化:因為定義一個static變數是為了保證只初始化一次,為大家所用,如果可以在類內初始化,會導致每個物件都包含該靜態成員,就為物件空間上了,不符合初心
  • 靜態成員函式

    • 所有物件共享同一個函式:因為記憶體開闢在全域性區
    • 靜態成員函式只能訪問靜態成員變數
class Person{
public:
    static void func(){
        cout << "Hey static!" <<endl;
        m_A = 10;  // 只能訪問靜態成員變數
        //m_B = 1;  //禁止訪問非靜態成員變數
        cout << m_A << endl;
    };
    static int m_A;  // 類內宣告
    int m_B;
};
int Person::m_A = 0;  // 需要在類外初始化
int main(){
    Person::func();  // 直接通過類名呼叫
    Person p;
    p.func();
}

作用:靜態成員函式主要為了呼叫方便,不需要生成物件就能呼叫,所以靜態成員函式相當於一個帶有名稱空間的全域性函式

就像我們呼叫數學Math裡的求最小值最大值,就可以直接呼叫Math::max();,而不需要生成例項。

4.3 cpp物件模型和this指標

4.3.1 成員變數和成員函式分開儲存

  • cpp中,類內的成員變數和成員函式分開儲存
  • 只有非靜態成員變數才屬於類的物件上
class empty_Person{

};
int main(){
    empty_Person pp;
    // cpp編譯器會給每個空物件分配一個位元組空間,是為了區分空物件佔記憶體的位置
    // 每個空物件都應該有一個獨一無二的記憶體地址
    cout << sizeof(pp) << endl;  // 1
}
class Person{
public:
int m_A;  // 非靜態成員變數,屬於類的物件上
    static int m_B;  // 靜態成員變數,不屬於類的物件上
    void func(){}  // 成員函式,不屬於類的物件上
    static void func(){}  // 靜態成員函式,不屬於類的物件上
};
int Person::m_B=0;
int main(){
    Person pp;
    cout << sizeof(pp) << endl;  // 4,只有非靜態成員變數在物件上
}

所以,只有非靜態成員變數在物件上。

4.3.2 this指標概念

通過上一節知道,每一個非靜態成員函式只會誕生一份函式例項,也就是說多個同類型的物件會共用一份程式碼。

那麼,這一塊程式碼是如何區分哪個物件呼叫自己呢?

cpp通過提供特殊的物件指標,this指標,解決上述問題。this指標指向被呼叫的成員函式所屬的物件

  • this指標是隱含每一個非靜態成員函式內的一種指標
  • this指標不需要定義,直接使用即可

this指標的用途:

  • 當形參和成員變數同名時,可用this指標來區分
  • 在類的非靜態成員函式中返回物件本身,可用return *this

經常用於鏈式程式設計思想

class Person{
public:
    Person(){
        m_weight=10;
    }
    Person& addweight(const int &weight){
        m_weight += weight;
        // 返回物件本身
        return *this;
    };
    int m_weight;
};
int main(){
    Person p;
    int a = 10;
    p.addweight(a).addweight(a);
    cout <<  p.m_weight << endl;  // 輸出30,addweight(a)是引用返回
}

class Person{
public:
    Person(){
        m_weight=10;
    }
    // 此處有變
    Person addweight(const int &weight){
        m_weight += weight;
        return *this;
    };
    int m_weight;
};
int main(){
    Person p;
    int a = 10;
    p.addweight(a).addweight(a);
    cout <<  p.m_weight << endl;  // 輸出20,addweight(a)是值返回,所以會呼叫Person的拷貝建構函式進行值傳遞,所以鏈式傳遞不成功
}

所以要注意,若想要實現單個物件的鏈式程式設計,需要引用返回,而不是值返回

4.3.3 空指標訪問成員函式

在cpp中,空指標是可以訪問其未呼叫物件相關成員變數的成員函式。

class Person{
public:
    void showsomething(){
        cout << "I'm a big man" << endl;
    }
};
int main(){
    Person *p = nullptr;
    p->showsomething();
}

但是需要注意的是,內部不能夠有呼叫成員變數,或者可以用判斷呼叫指標是否為空進行判斷是否執行後續程式:

if (this==nullptr){
return;
}

4.3.4 const修飾成員函式

常函式

  • 成員函式後加const後,我們稱這個函式為常函式
  • 常函式內不可以修改成員屬性
  • 成員屬性宣告時加關鍵字mutable後,在常函式中依然可以修改

常物件

  • 宣告物件前加const,稱該物件為常物件
  • 常物件只能呼叫常函式

this指標的本質就一個指標常量,它的指向不可以修改

class Person{
public:
    void showsomething(){
        this->m_weight = 20;
        // this = nullptr //這是錯誤的,因為指標常量的指向不可以修改
    }
    int m_weight;
};

若在成員函式後加const,則指標常量進一步進化為修飾常量,它的指向不可以改變,而且它指向的值也不可以改變

class Person{
public:
    void showsomething() const{
        this->m_weight = 20;  // 不可修改
        this->m_height = 170;  // 加了mutable關鍵則之後就可以修改
        // this = nullptr //這是錯誤的,因為指標常量的指向不可以修改
    }
    int m_weight;
    mutable int m_height;
};

若在物件前面加const,則該物件變為常函式,不能修改值,且只能夠呼叫常函式

const Person p;

4.4 友元

友元就是讓朋友可以訪問我的私有成員

根據朋友的型別不同,可分為:

  • 全域性函式做友元
  • 類做友元
  • 成員函式做友元
class Person{
    friend void goodFriend(Person *person);  // 宣告該全域性函式為Person的朋友,可訪問其私有成員
    friend class others;  // 宣告該類為Person的朋友,可訪問其私有成員
    friend void others::getFriendMoney();  // 宣告others的成員函式為Person的朋友,可訪問其私有成員
public:
    int hair;
private:
    int money
};

//全域性函式
void goodFriend(Person *person){
  cout << person->money << endl;  // 訪問Person的私有成員  
}
//類
class others{
public:
    void getFriendMoney(){
        Person p;
        cout << p.money << endl;
    }
}

4.5 運算子過載

運算子過載:對已有的運算子重新進行定義,賦予其另一種功能,以適應不同的資料型別

4.5.1 加號運算子過載

兩種過載方式:

  • 通過成員函式過載+號
  • 通過全域性函式過載+號

運算子過載,也可以發生運算子過載

通過成員函式過載+號:

// 成員函式過載本質呼叫
Person p3 = p1.operator+(p2);
class others{
public:
    Person operator+(Person &p){
        Person temp;
        temp._age = this->_age + p._age;
        return temp;
    }
int _age;
}
int main(){
    Person p1;
    p1._age = 10;
    Person p2;
    p2._age = 20;
    Person p3 = p1 + p2;  //加號運算子過載
    cout << p3._age << endl;
}

通過全域性函式過載+號:

// 全域性函式過載本質呼叫
Person p3 = operator+(p1, p2);
Person operator+(Person &p1, Person&p2){
    Person tmp;
    tmp._age = p1._age + p2._age;
    return tmp;
}

運算子過載,也可以發生運算子過載

Person operator+(Person &p1, int _age);

4.5.2 左移運算子過載

可用於:

cout << p << endl;
  • 不能利用成員函式過載左移運算子,因為希望cout在物件的左邊
  • 只能利用全域性函式過載左移運算子
void operator<<(ostream &cout, Person &p){
cout << p.b << endl;
}
cout << p;
// 還可以採取連式程式設計的思想
ostream & operator<<(ostream &cout, Person &p){
    cout << p.b << endl;
    return cout;
}
cout << p << endl;

如果想讓過載運算子函式訪問私有成員,可用利用友元,將函式過載運算子變為好朋友。

4.5.3 遞增運算子

首先,遞增分為前置遞增和後置遞增:

cout << i++ << endl;  // 先輸出i,再執行+ 
cout << ++i << endl;  // 先執行+,s 

具體的實現如下:

class myInteger{
    friend ostream& operator<<(ostream& cout, myInteger myint);
public:
    myInteger(){
        m_num=0;
    }
    // 過載前置++運算子,返回引用是為了一直對同一資料進行遞增操作,類似於內建資料型別
    myInteger operator++(){
       m_num++;
       return *this;  // 返回引用
    }
    // 後置++運算子,利用佔位引數int進行區分
    myInteger operator++(int){
        myInteger tmp = *this;
        m_num++;
        return tmp;  // 返回值,返回的是一個拷貝,返回的一瞬間會執行拷貝建構函式
    }
private:
    int m_num;
};

ostream& operator<<(ostream& cout, myInteger myint){
    cout << myint.m_num;
    return cout;
}
int main(){
    myInteger myint;
    cout << myint++ << endl;
    cout << ++myint << endl;
    cout << myint << endl;
    return 0;
}

需要注意的點:

  • 前置++運算子返回的是引用,後置++運算子返回的是值
  • 後置運算子過載需要利用int進行佔位來表示

4.5.4 賦值運算子過載

賦值運算子過載通常解決的是編譯器預設的賦值操作是進行淺拷貝的拷貝建構函式呼叫,而如此會帶來的記憶體重複釋放問題

所以解決方式是:在賦值運算子過載中進行深拷貝,即在堆空間上建立一個新的記憶體空間。

class Cat{
public:
    Cat(int age){
        m_age = new int(age);
    }
    ~Cat(){
        if (m_age != nullptr){
            delete m_age;  // 堆區開闢的記憶體需要程式設計師手動釋放
            m_age = nullptr;
        }
    }
    int *m_age;
    Cat& operator=(Cat &c){
        if (m_age != nullptr){
            delete m_age;
            m_age = nullptr;
        }
        m_age = new int(*(c.m_age));
        return *this;
    }
};

ostream& operator<<(ostream& cout, Cat &c){
    cout << *(c.m_age);
    return cout;
}

int main(){
    Cat p1(10);
    Cat p2(20);
    p2 = p1;
    cout << p2 << endl;
    return 0;
}

4.5.5 關係運算符過載

作用:過載關係運算符,可以讓兩個自定義型別的物件進行對比操作。

實現起來比較簡單,這裡就不多加介紹了,記得返回的是bool型別即可。

4.5.6 函式呼叫運算子過載

  • 函式呼叫運算子()也可以過載
  • 由於過載後使用方式非常像函式的呼叫,因此稱為仿函式
  • 仿函式沒有固定寫法,非常靈活
class Cat{
public:
    Cat(int age, string name){
        m_name = name;
        m_age = age;
    }
    // 函式運算子過載
    void operator()(){
        cout << "Hi, I'm " << this->m_name << endl; 
    }
    int m_age;
    string m_name;
};

int main(){
    Cat c(10, "Tom");  // 注意,string型別要用雙引號
    c();
    // 匿名函式物件呼叫
    Cat(10, "Jack")();  // 還可以這樣呼叫,呼叫完後該物件生命便結束
    return 0;
}
  • 函式呼叫運算子過載非常有用,可能pytorch中的nn.module.forward就是被nn.module的函式運算子過載所呼叫。

  • 還可以利用匿名物件進行函式呼叫,也側面說明了運算子的呼叫必須要有例項化物件,不能說簡單地通過類來呼叫。

4.6 繼承

繼承,為了減少重複的程式碼。

4.6.2 繼承方式

繼承的語法:

class 子類 : 繼承方式 父類

繼承方式可以分為三類:

  • 公共繼承
  • 保護繼承
  • 私有繼承

繼承方式的意思就是,從父類繼承得到的成員屬性在本類將如何變化

image-20210110125932819

4.6.3 繼承中的物件模型

class Person{
public:
    int m_A;
    void saysomething(){
        cout << "Hello, world!" << endl;
    }
private:
    int m_B;
protected:
    int m_C;
};

class Son : public Person{
private:
    int m_D;
};

int main(){
    Person p;
    Son s;
    cout << sizeof(p) << endl;  // 12
    cout << sizeof(s) << endl;  // 16
    s.saysomething();  // Hello,world!
}
  • 父類中所有非靜態成員屬性都會被子類繼承

  • 父類中私有屬性成員是被編譯器隱藏了,因此是訪問不到的,但是的確被繼承

4.6.4 繼承中的構造和析構順序

順序:

  1. 父類構造
  2. 子類構造
  3. 子類析構
  4. 父類析構

4.6.5 同名成員處理

  1. 子類物件可以直接訪問到子類中同名成員
  2. 子類物件加作用域可以訪問到父類同名成員
  3. 當子類與父類擁有同名的成員函式,子類會隱藏父類中同名成員函式,加作用域可以訪問到父類中同名函式
class Person{
public:
    void saysomething(){
        cout << "Hello, dad's world!" << endl;
    }
};

class Son : public Person{
public:
    void saysomething(){
        cout << "Hello, son's world!" << endl;
    }
};

int main(){
    Son s;
    s.Person::saysomething();  // Hello, dad's world!
    s.saysomething();  // Hello, son's world!
}

對於靜態成員也類似,特別之處在於,靜態成員不僅僅可以通過物件呼叫,還可以通過類名作用域來呼叫。

4.6.7 多繼承語法

cpp允許一個類繼承多個類

語法:

class 子類 : 繼承方式 父類1, 繼承方式 父類2...

對於同名的屬性成員,也需要加類名作用域加以區分。

cpp實際開發中不建議使用多繼承

4.6.8 菱形繼承與虛繼承

菱形繼承,如下圖所示:

Sport Basketball Swim SwimBasket

在使用菱形繼承方式時,SwimBasket繼承了BasketballSwimm_score同名成員變數,雖然同名,但兩者的作用域來源不一樣,所以Swimbasket物件繼承的時候會建立兩個記憶體空間來儲存它們。故SwimBasket想要呼叫m_score時,需要特別指定父類名。

class Sport{
public:
    int m_score;
};
class Swim : virtual public Sport{};
class Basketball : virtual public Sport{};
class SwimBasket : public Swim, public Basketball{};

int main(){
    Basketball b;
    Swim s;
    SwimBasket p;
    p.Basketball::m_score = 100;
    p.Swim::m_score = 200;
    cout << p.Basketball::m_score << endl; // 100
    cout << p.Swim::m_score << endl;  // 200
    // cout << p.m_score << endl;  // 錯誤,無法呼叫
    cout << sizeof(b) << endl;  // 4
    cout << sizeof(s) << endl;  // 4
    cout << sizeof(p) << endl;  // 8
}

Swim和類Basketball各自從類Sport派生(非虛繼承且假設類Sport包含一些資料成員),且類SwimBasket同時多繼承自類SwimBasketball,那麼SwimBasket的物件就會擁有兩套Sport的例項資料(可分別獨立訪問,一般要用適當的消歧義限定符)。但是如果類SwimBasketball各自虛繼承了類Sport,那麼SwimBasket的物件就只包含一套類Sport的例項資料。其原理是,間接派生類SwimBasket穿透了其父類(上面例子中的SwimBasketball),實質上直接繼承了虛基類Sport

其中,虛繼承是通過虛指標和虛表實現的。

class Sport{
public:
    int m_score;
};
class Swim : virtual public Sport{};
class Basketball : virtual public Sport{};
class SwimBasket : public Swim, public Basketball{};

int main(){
    Basketball b;
    Swim s;
    SwimBasket p;
    p.Basketball::m_score = 100;
    p.Swim::m_score = 200;
    cout << p.Basketball::m_score << endl; // 200
    cout << p.Swim::m_score << endl;  // 200
    cout << p.m_score << endl;  // 200
    cout << sizeof(b) << endl;  // 16
    cout << sizeof(s) << endl;  // 16
    cout << sizeof(p) << endl;  // 24
}

其中的記憶體模型如下所示

class SwimBasket                              
object                                         
    0 - class Swim (primary base)             
    0 -   vptr_Swim                               
    8 - class Basketball                       
    8 -   vptr_Basketball       
   16 -   int m_score                     
sizeof(C): 24    align: 8           

linux64中cpp編譯器的int型佔據4個位元組,指標佔據8個位元組。理論上應該是(Swim的虛指標+Basketball的虛指標+SwimBasket的m_score成員變數)=20,但由於需要進行8位元組對齊,所以sizeof(SwimBasket)=24。

當我們修改p.Swim::m_score時候,其他類的虛指標就會根據虛表中相對p.Swim的偏移量動態同步進行修改,如此達到對同一記憶體空間操縱的目的。

所有當2*8+4*x<=8x===>x>=4的時候,就開始節省記憶體啦~~~!

4.7 多型

4.7.1 多型的基本概念

多型是cpp面向物件三大特徵之一

多型分為兩類

  • 靜態多型:函式過載和運算子過載屬於靜態多型,複用函式名
  • 動態多型:派生類和虛擬函式實現執行時多型

靜態多型和動態多型區別:

  • 靜態多型的函式地址早繫結 - 編譯階段確定函式地址
  • 動態多型的函式地址晚繫結 - 執行階段確定函式地址

以下是動態多型的一個例子:

class Person{
public:
    virtual void saysomething(){
        cout << "I'm a person!" << endl;
    }
};
class Kid : public Person{
public:
    void saysomething(){
        cout << "I'm a kid!" << endl;
    }
};

void test(Person &p){
    p.saysomething();
};
int main(){
    Person p;
    Kid k;
    test(p);  // "I'm a person!"
    test(k);  // "I'm a kid!"
    cout << sizeof(p) << endl;  // 8
    cout << sizeof(k) << endl;  // 8
}

總結:

多型滿足條件:

  • 有繼承關係
  • 子類重寫父類中的虛擬函式

多型使用條件

  • 父類指標或引用指向子類物件

重寫:函式的返回值型別,函式名,引數列表完全一致稱為重寫。

多型的原理:

關鍵在於,含有虛擬函式宣告的類會建立一個表(vtable),在vtable中,編譯器放置特定類的虛擬函式的地址,該表被一個**虛擬函式表指標(vptr)**祕密地指向。

當繼承該類的子類重寫了虛擬函式後,子類中的虛擬函式表指標將指向自身的表,而表中有所重寫的函式。而當我們傳入Person &p的是Kid物件時,在執行階段,由於"多型機制",Kid物件將會呼叫Kid物件的虛擬函式表指標,而該指標指向的是Kid物件的vtable,表中有自己重寫的虛擬函式&Kid::saysomething()

簡單一句話就是,多型就是根據傳入不同的子類物件,來指向對應子類的虛擬函式表。

4.7.3 純虛擬函式和抽象類

在多型中,通常父類中虛擬函式的實現是毫無意義的,主要都是呼叫子類重寫的內容,因此可以將虛擬函式改為純虛擬函式

純虛擬函式語法:virtual 返回值型別 函式名 (引數列表) = 0;

當類中有了純虛擬函式,這個類也稱為抽象類

通俗解釋:其實這個功能就像是規定一套正規化,一套必須遵守的正規化

抽象類特點

  • 無法例項化物件
  • 子類必須重寫抽象類中的純虛擬函式,否則也屬於抽象類

4.7.5 虛析構和純虛析構

**問題背景:**多型使用時,如果子類有屬相開闢到堆區,那麼父類指標在釋放時無法呼叫到子類的析構程式碼

問題程式碼:

class Animal{
public:
    Animal(){
        cout << "Animal construct"<<endl;
    }
    ~Animal(){
        cout << "Animal release"<<endl;
    }
    virtual void speak()=0;
};

class Cat : public Animal{
public:
    Cat(string name){
        m_name = new string(name);
        cout << "Cat construct"<<endl;
    }
    ~Cat(){
        if (m_name != nullptr){
            delete m_name;
            m_name = nullptr;
            cout << "Cat release"<<endl;
        }
    }
    virtual void speak(){
        cout << *m_name << " Cat is speaking!" << endl;
    }
    string *m_name;
};

int main(){
    Animal *animal = new Cat("Tom");
    animal->speak();
    delete animal;
}
// output
// Animal construct
// Cat construct
// Tom Cat is speaking!
// Animal release

產生原因,我們是用父類的指標去呼叫物件,所以釋放父類指標的時候,不會呼叫子類的解構函式。所以導致子類如果有堆區屬性,則會出現記憶體洩漏。

解決辦法,利用虛析構:

// 虛析構
virtual ~Animal(){
cout << "Animal release"<<endl;
}

或者,利用純虛析構:

class Animal{
public:
    Animal(){
        cout << "Animal construct"<<endl;
    }
    virtual ~Animal()=0
    virtual void speak()=0;
};
Animal::~Animal(){
    cout << "Animal release" << endl;
}
  • 注意,純虛析構需要宣告也需要實現
  • 有了純虛構之後,這個類也屬於抽象類,無法例項化物件。