深入C++06:深入掌握OOP最強大的機制

booker發表於2022-05-12

?深入掌握OOP最強大的機制

1. 繼承的基本意義

類與類之間的關係:①組合:a part of ... 一部分的關係;②繼承: a kind of ... 屬於同一種的關係;

繼承的本質:a. 程式碼的複用(相同的特徵行為抽象出來作為基類)

三種繼承關係以及各種注意點看初識C++中的繼承筆記;

訪問許可權表複習:

image-20220326152235361

問題:如果我們不寫繼承方式的話,那它會以什麼方式繼承呢?例子如class B : A

要看具體情況:要看派生類是用class定義還是用struct定義!!!!①class定義:預設繼承方式是private;②struct定義:預設繼承方式是public;

所以上述的例子相當於class B : private A

2.派生類的構造過程

看??一定要去看初識C++類繼承派生相關筆記;都涵蓋了這些內容!!!!!!

問題一:派生類如何初始化從基類繼承來的成員變數呢?

1.派生類可以從基類繼承來所有的成員(成員變數和成員方法),除建構函式和解構函式。
2.因此我們通過呼叫基類相應的建構函式來初始化。
3.派生類的建構函式和解構函式負責初始化和清理派生類部分;派生類從基類繼承來的成員的初始化和清理由基類的構造和解構函式來負責。

問題二:派生類物件析構和構造過程是如何的呢?

1.派生類呼叫基類的建構函式,初始化從基類繼承來的成員。
2.呼叫派生類自己的建構函式,初始化派生類自己特有的成員。
3.派生類物件的作用域到期,先呼叫派生類的解構函式,釋放派生類成員可能佔用的外部資源。
4.呼叫基類的解構函式,釋放派生類記憶體中從基類繼承來的成員可能佔用的外部資源。

3.過載、隱藏、覆蓋

看??一定要去看初識C++類繼承派生相關筆記;都涵蓋了這些內容!!!!!!

基類派生類裡面相關的成員方法我們經常使用三種關係來描述它們,即:過載、隱藏、覆蓋關係

過載: 一組函式要過載,必須處在同一個作用域當中;而且函式名字相同,引數列表不同。

隱藏(作用域的隱藏): 在繼承結構當中,派生類的同名成員,把基類的同名成員給隱藏掉了,即作用域的隱藏,只要名字即可相同!

覆蓋: 父子類中的同名、同引數、同返回值的多個成員函式,從子到父形成的關係稱為覆蓋關係,虛擬函式中會發生,看下一節

隱藏關係例子:

class Base
{
public:
	Base(int data = 10) :ma(data){}
	void show()//1
	{
		cout << "Base::show()" << endl;
	}
	void show(int)//2
	{
		cout << "Base::show(int)" << endl;
	}
private:
	int ma;
};

class Derive : public Base
{
public:
	Derive(int data = 20):Base(data), mb(data){}
    void show()//3
	{
		cout << "Derive::show()" << endl;
	}
private:
	int mb;
};

int main()
{
	Derive d;
	d.show();
	d.show(10);//報錯,因為Derive中沒有show(int)方法;如果把Derive中show方法刪除,既可以成功,Derive中找不到函式,會到Base作用域中找;
	return 0;
}

1、2屬於函式過載關係,1和3、2和3都屬於隱藏關係;

型別轉換:之前筆記有寫得很清楚了!總結一下:

在繼承結構中進行上下的型別轉換,預設只支援從下到上的型別的轉換。除非進行強轉,但強轉不安全,會涉及記憶體的非法訪問。

4.虛擬函式、靜態繫結和動態繫結(非常核心重點)

虛擬函式:在某基類中宣告為 virtual 並在一個或多個派生類中被重新定義的成員函式。

look??:
1.一個類裡面定義了虛擬函式,那麼編譯階段(不是執行階段),編譯器需給這個類型別產生一個唯一的vftable虛擬函式表。虛擬函式表中主要儲存的內容就是RTTI指標和虛擬函式的地址。當程式執行時,每一張虛擬函式表都會載入到記憶體的.rodata區(只讀資料區)。
2.一個類裡面定義了虛擬函式,那麼這個類定義的物件,其執行時,記憶體中開始部分,多儲存一個vfptr虛擬函式指標,指向相應型別的虛擬函式表vftable。一個型別定義的n個物件它們的vfptr指向都是同一張虛擬函式表。
3.一個類裡面虛擬函式的個數不影響的物件記憶體大小,影響的是虛擬函式表的大小。
4.如果派生類中的方法和基類繼承來的某個方法,返回值、函式名、引數列表都相同,而且基類的方法是virtual虛擬函式,那麼派生類的這個方法自動處理成虛擬函式,即覆蓋關係。

靜態繫結和動態繫結的更好理解:

①靜態繫結:

#include <iostream>
#include <typeinfo>
using namespace std;

class Base
{
public:
	Base(int data = 10): ma(data){}
	void show()
	{
		cout << "Base::show()" << endl;
	}
	void show(int)
	{
		cout << "Base::show(int)" << endl;
	}
protected:
	int ma;
};

class Derive : public Base
{
public:
	Derive(int data = 20):Base(data),mb(data){}
	void show()
	{
		cout << "Derive::show()" << endl;
	}
private:
	int mb;
};

int main()
{
	Derive d(50);
	Base *pb = &d;//上轉型
	pb->show();//靜態繫結  call Base::show(01612DAh)
	pb->show(10);//靜態繫結 call Base::show(01612B2h)
	
	cout << sizeof(Base) << endl;//4
	cout << sizeof(Derive) << endl;//8

	cout << typeid(pb).name() << endl;//class Base * __ptr64
	cout << typeid(*pb).name() << endl;//class Base

	return 0;
}

編譯期間指定了函式的呼叫,這就是是靜態繫結,彙編圖片如下:

image-20220328164509576

結果:image-20220328170522453

②動態繫結:

class Base
{
public:
	Base(int data = 10): ma(data){}
	virtual void show()//虛擬函式
	{
		cout << "Base::show()" << endl;
	}
	virtual void show(int)//虛擬函式
	{
		cout << "Base::show(int)" << endl;
	}
protected:
	int ma;
};
class Derive : public Base
{
public:
	Derive(int data = 20):Base(data),mb(data){}
	void show() //繼承的虛擬函式
	{
		cout << "Derive::show()" << endl;
	}
private:
	int mb;
};

如果一個類裡面定義了虛擬函式,那麼編譯階段,編譯器需給這個類型別產生一個唯一的vftable虛擬函式表。虛擬函式表中主要儲存的內容就是RTTI指標和虛擬函式的地址。該類型別各物件中的vfptr指向第一個虛擬函式的起始地址! 基類與派生類中vftable表如圖所示:

img

img

結果:image-20220328170719931

解析一:對結果上面動態繫結與靜態繫結執行流程
分析一下:pb->show();
pb->show();編譯階段發現show()為Base型別,到基類作用域檢視Base::show(),若show()為普通函式,就進行靜態繫結call Base::show();若編譯階段指標為Base型別,到基類作用域檢視Base::show(),發現show()為虛擬函式,就進行動態繫結,動態繫結彙編如下:

1.mov eax,dword ptr[pb] //將虛擬函式表的地址vfptr放入eax暫存器
2.mov ecx,dword ptr[eax] //將vfptr存的地址的4位元組記憶體&Derive::show()地址放入ecx
3.call ecx //呼叫ecx,取虛擬函式地址,只有在執行時候才知道暫存器的地址,找到哪個地址呼叫哪個函式,這就是動態繫結,執行時期的繫結;

解析二:

sizeof變化的原因: 有虛擬函式多了vfptr指標;

解析三:

pb的型別:如果Base沒有虛擬函式,* pb識別的就是編譯時期的型別。* pb就是Base型別;
如果Base有虛擬函式,* pb識別的就是執行時期的型別:RTTI型別,即Derive型別;

建構函式:(呼叫任何函式都是靜態繫結的)
1.建構函式不能稱為虛擬函式; 原因:建構函式是生成物件的過程,物件還沒存在,必然沒有vfptr指標,也找不到vftable;
2.建構函式中呼叫虛擬函式也不會發生動態繫結。 原因:派生類物件構造順序 基類 --> 派生類,此時基類呼叫虛擬函式,派生類還沒有構造,無法呼叫動態繫結派生類的虛擬函式;(這裡不一定正確,多看看)

static靜態成員方法:靜態成員方法呼叫不依賴物件,因此也不能成為虛擬函式。

5.虛解構函式

解構函式:可以成為虛擬函式,呼叫時候物件存在,在解構函式前加上virtual關鍵字。

看一段程式碼:

class Base
{
public:
	Base(int data) :ma(data)
	{
		cout << "Base()" << endl;
	}
	~Base()
	{
		cout << "~Base()" << endl;
	}
	virtual void show()
	{
		cout << "call Base::show()" << endl;
	}
protected:
	int ma;
};

class Derive : public Base
{
public:
	Derive(int data):Base(data), mb(data),ptr(new int(data))
	{
		cout << "Derive()" << endl;
	}
	~Derive()
	{
		delete ptr;
		cout << "~Derive() " << endl;
	}
private:
	int mb;
	int *ptr;
};

int main()
{
	Base *pb = new Derive(10);
	pb->show();//靜態繫結pb Base*   *pb Derive
	delete pb;

	return 0;
}

執行結果:

在這裡插入圖片描述

問題:派生類的解構函式沒有被呼叫到,記憶體洩露!

問題分析:pb的型別是Base型別,因此delete呼叫解構函式先去Base中找Base::~Base(),對於解構函式的呼叫就是靜態繫結,沒有機會呼叫派生類的解構函式,最後發生記憶體洩露!

解決方法: 將基類的解構函式定義為虛解構函式,派生類的解構函式自動成為虛擬函式。 pb的型別是Base型別,呼叫析構時去Base中找Base::~Base發現它為虛擬函式,發生動態繫結。而在派生類的虛擬函式表找到:&Derive:: ~derive,所以用派生類解構函式將自己部分進行析構,再呼叫基類的解構函式將基類部分析構。

問題:什麼時候需要把基類的解構函式必須實現成虛擬函式?

基類的指標(引用)指向堆上new出來的派生類物件的時候,delete呼叫解構函式的時候,必須發生動態繫結,否則會導致派生類的解構函式無法呼叫

6.再談動態繫結

問題:虛擬函式的呼叫一定就是動態繫結嗎?

不是!!!類建構函式中,呼叫虛擬函式是靜態繫結;物件本身呼叫虛擬函式,也是靜態繫結;

直接上例子:

class Base
{
public:
	Base(int data = 0):ma(data){}
	virtual void show()
	{
		cout << "Base::show()" << endl;
	}
protected:
	int ma;
};

class Derive : public Base
{
public:
	Derive(int data = 0):Base(data), mb(data){}
	void show()
	{
		cout << "Derive::show()" << endl;
	}
private:
	int mb;
};

int main()
{
	Base b;
	Derive d;

	//①物件本身呼叫虛擬函式,靜態繫結!因為自己怎麼樣都是呼叫自己的函式;沒有轉型;
	b.show();//虛擬函式 call Base::show();
	d.show();//虛擬函式 call Derive::show();
    
    //②派生類(基類也一樣)指標呼叫派生類物件,派生類(基類也一樣)引用呼叫派生類物件,是動態繫結!
    Derive *pd1 = &d;
	pd1->show();//Derive::show();
	Derive &rd1 = d;
	rd1.show();//Derive::show();
    Base *pb2 = &d;//基類指標指向派生類物件
    pb2->show();//Derive::show();
    
    //③強制型別轉換(雖然會有訪問錯誤),是動態繫結!
    Derive *pd3 = (Derive*)&b;
	pd3->show(); //最終還是呼叫Base::show(),因為是Base物件

	return 0;
}
//可通過反彙編檢視是否是動態繫結;

?總結:

  • 用物件本身呼叫虛擬函式,是靜態繫結。
  • 動態繫結:虛擬函式前面必須是指標或引用呼叫才能發生動態繫結:基類指標指向基類物件,基類指標指向派生類物件,都是動態繫結。
  • 如果不是通過指標或者引用來呼叫虛擬函式,那就是靜態繫結。

7.理解多型到底是什麼

如何解釋多型?(面試重點)

多型:多型字面理解為多種形態。多型分為靜多型與動多型。

多型分為:

靜態的多型:編譯時期的多型:函式過載、模板(函式模板、類别範本)

動態的多型: 執行時期的多型。在繼承結構中,基類指標(引用)指向派生類物件,通過該指標(引用)呼叫同名覆蓋方法(虛擬函式),基類指標指向哪個派生類物件,就會呼叫哪個派生類物件的同名覆蓋方法,成為動多型。多型底層是通過動態繫結來實現的。 基類指標指向誰就訪問誰的vfptr,繼而繼續訪問vftable,從vftable拿出來的就是相應的派生類的方法;

靜態多型、動態多型例子:

//靜態的多型:函式過載
bool compare(int, int){}
bool compare(double, double){}

compare(10, 20); //call compare_int_int,在編譯階段就確定好呼叫的函式版本
compare(10.5, 20.5);//call compare_double_double

//靜態的多型:模板,模板例項化發生在編譯階段
template<typename T>
bool compare(T a, Tb){}

compare(10, 20); //=>int 例項化一個compare<int>
compare(10.5, 20.5); //=>double 例項化一個compare<double>

//動態多型:以上個例子為例:
//動物基類
class Animal
{
public:
	Animal(string name):_name(name){}
	virtual void bark(){}
protected:
	string _name;
};

//動物實體類
class Cat : public Animal
{
public:
	Cat(string name):Animal(name){}
	void bark()
	{
		cout << _name << "bark:miao miao!" << endl;
	}
};

class Dog : public Animal
{
public:
	Dog(string name):Animal(name){}
	void bark()
	{
		cout << _name << "bark:wang wang!" << endl;
	}
};

class Pig : public Animal
{
	public:
	Pig(string name):Animal(name){}
	void bark()
	{
		cout << _name << "bark:heng heng!" << endl;
	}
};
//錯誤寫法:這樣高耦合一一對應地寫,若新增更多的新的動物,派生類物件越多,bark()方法還需要繼續增加。相應的實體類若刪除,其對應介面也要刪除,不滿足“開-閉”原則;
/**
void bark(Cat &cat)
{
	cat.bark();
}

void bark(Dog &dog)
{
	dog.bark();
}

void bark(Pig &pig)
{
	pig.bark();
}
**/
//正確寫法:使用統一的基類型別接受派生類物件,再增加新的派生型別,API介面不用修改,很方便;
void bark(Animal *p)
{
	p->bark();
}

int main()
{
	Cat cat("小貓");
	Dog dog("小狗");
	Pig pig("小豬");

	bark(&cat);
	bark(&dog);
	bark(&pig);

	return 0;
}

由此也可以得知:繼承的好處:

可以做程式碼的複用;②在基類中給所有派生類提供統一的虛擬函式介面,讓派生類進行重寫,然後就可以使用多型

8.理解抽象類

抽象類:擁有純虛擬函式的類叫做抽象類

注意?:抽象類不能再例項化物件了,但是可以定義指標和引用變數

問題一:抽象類和普通類有什麼區別 ?

抽象類一般不是用來定義某一個實體型別的,不能例項化物件的,但是可以定義指標和引用變數。它所做的事情是:1.讓所有的派生類類通過繼承基類直接複用該屬性。2.給所有的派生類保留統一的覆蓋/重寫介面;普通類定義指標,引用變數都可以,例項化物件也可以

問題二:一般把什麼類設計成抽象類?

基類。 這個基類並不是為了抽象某個實體型別而存在的(比如動物抽象了無意義,貓狗這些實體可以抽象),它所做的事情是:1.讓所有的派生類類通過繼承基類直接複用該屬性。2.給所有的派生類保留統一的覆蓋/重寫介面;

例子:改動一下上面的Animal類

因為我們定義Animal不是為了抽象某個實體,而是①提供string_name給所有動物實體複用這個屬性;②給派生類有bark()方法,讓所有派生類有統一的覆蓋/重寫介面;

我們初衷就是改成抽象類,那麼修改一下:

/動物基類  泛指  類-》抽象一個實體的型別
class Animal
{
public:
	Animal(string name):_name(name){}
	virtual void bark() = 0;//純虛擬函式!!!!
protected:
	string _name;
};

例子:看看車的例子:

#include <iostream>
#include <typeinfo>
#include <string>
using namespace std;

class Car
{
public:
	Car(double oil, string name): _oil(oil), _name(name) {}
	double getLeftMiles() {
		return _oil * this->getMilePerGallon();
	}
	string getName() {
		return _name;
	}
private:
	double _oil;
	string _name;
	virtual double getMilePerGallon() = 0;//純虛擬函式,根據具體的汽車而定1L油跑的公里數,其實單獨設成一個變數也可以解決問題;
};
class BMW : public Car
{
public:
	BMW(double oil, string name):Car(oil, name) {}
	double getMilePerGallon() {
		return 10.1;
	}
private:
};
class Audi : public Car
{
public:
	Audi(double oil, string name) :Car(oil, name) {}
	double getMilePerGallon() {
		return 11.1;
	}
private:
};
//給外部提供一個統一的獲取汽車剩餘路程數的API
void showLeftMiles(Car &p) {
	cout << "我的車型為:" << p.getName() << " 我還可以走:" << p.getLeftMiles() << endl;
}
int main() {
	Audi* temp = new Audi(10, "奧迪");
	BMW* test = new BMW(10, "寶馬");
	showLeftMiles(*temp);
	showLeftMiles(*test);
	return 0;
}

結果:

image-20220328233909018

9.繼承多型筆試題實戰

題目一:貓狗叫問題

//動物基類  泛指  類-》抽象一個實體的型別
class Animal
{
public:
	Animal(string name):_name(name){}
	//純虛擬函式
	virtual void bark() = 0;
protected:
	string _name;
};

//動物實體類
class Cat : public Animal
{
public:
	Cat(string name):Animal(name){}
	void bark()
	{
		cout << _name << "bark:miao miao!" << endl;
	}
};

class Dog : public Animal
{
public:
	Dog(string name):Animal(name){}
	void bark()
	{
		cout << _name << "bark:wang wang!" << endl;
	}
};

class Pig : public Animal
{
	public:
	Pig(string name):Animal(name){}
	void bark()
	{
		cout << _name << "bark:heng heng!" << endl;
	}
};

int main()
{
	Animal *p1 = new Cat("加菲貓");
	Animal *p2 = new Dog("二哈");

	int *p11 = (int*)p1;
	int *p22 = (int*)p2;
	int tmp = p11[0];//p11[0]訪問的Cat的前4個位元組
	p11[0] = p22[0];//p22[0]訪問的Cat的前4個位元組
	p22[0] = tmp;//Cat的vfptr存放Dog的vftable地址,Dog的vfptr存放Cat的vftable地址,輸出結果交換

	p1->bark();
	p2->bark();

	delete p1;
	delete p2;

	return 0;
}

所以輸出結果:image-20220329004418877

題目二:繼承結構中基類派生類同名覆蓋方法不同預設值問題

class Base
{
public:
	virtual void show(int i = 10)
	{
		cout << "call Base::show i:" << i << endl;
	}
};

class Derive : public Base
{
public:
	virtual void show(int i = 20)
	{
		cout << "call Derive::show i:" << i << endl;
	}
};

int main()
{
	Base *p = new Derive();
	p->show();
    /*
    我們來看一下函式呼叫過程,呼叫一個函式時要先壓引數(編譯階段),若沒有傳入實參,會將形參預設值壓棧。執行時候才發生動態繫結呼叫派生類的show方法,編譯階段編譯器只能看見基類Base的show,將基類的10壓棧,引數壓棧在編譯時期確定好的。真正呼叫時不管是靜態還是動態繫結,只要是show()方法形參壓棧的值是固定的10;
       /*

        push 0Ah =》函式呼叫,引數壓棧在編譯時期確定好的
        mov eax, dword ptr[p]
        mov ecx, dword ptr[eax]
        call ecx
        */
    */
	delete p;

	return 0;
}

所以結果為:image-20220329010730800

題目三:派生類的方法寫成私有的可以正常呼叫嗎

class Base
{
public:
	virtual void show()
	{
		cout << "call Base::show" << endl;
	}
};

class Derive : public Base
{
private:
	virtual void show()
	{
		cout << "call Derive::show" << endl;
	}
};

int main()
{
	Base *p = new Derive();
	p->show();
	delete p;

	return 0;
}

成功呼叫:image-20220329011002643

分析一下:p->show()最終能夠呼叫Derive的show,是在執行時期才確定的。但是成員方法的訪問許可權是不是public,是在編譯階段就需要確定好的,編譯階段會檢測程式符不符合c++規則,比如訪問許可權等編譯階段編譯器只能看見Base中的show為public,編譯器可以呼叫;但最終呼叫的是基類的方法還是派生類的方法取決於形成的彙編指令是靜態繫結還是動態繫結,因為為動態繫結最後成功呼叫派生類的show。

如果基類Base的show改成private就會錯誤!

問題四:下面兩段程式碼是否正確

class Base
{
public:
	Base()
	{
		cout << "call Base()" << endl;
		clear();
	}
	void clear()
	{
		memset(this, 0, sizeof(*this));//Base大小賦為0
	}
	virtual void show()
	{
		cout << "call Base::show() " << endl;
	}
};

class Derive : public Base
{
public:
	Derive()
	{
		cout << "call Derive() " << endl;
	}
	void show()
	{
		cout << "call Derive::show() " << endl;
	}
};

int main()
{
	//情況1
	Base *pb1 = new Base();
	pb1->show();//動態繫結
	delete pb1;

	//情況2
	Base *pb2 = new Derive();;
	pb2->show();//動態繫結
	delete pb2;

	return 0;
}

情況1:系統奔潰image-20220329011956722

image-20220329011917054

分析一下:基類的物件記憶體如圖,vfptr指向Base vftable,當呼叫clear()時,將基類的物件記憶體清為0,虛擬函式指標也變為0地址,進行動態繫結時,訪問不到,呼叫時出錯,程式崩潰。

在這裡插入圖片描述

情況2:成功輸出image-20220329012325482

分析一下:我們vfptr裡面儲存的是vftable的地址,我們首先底層來看指令哪裡將vfptr寫入vftable中。

push ebp 
mov ebp,esp//函式幀棧底
sub esp,4Ch
rep stos esp<->ebp 0xCCCCCCCC //linux不會初始化

//先了解一下這個:①每一個函式進來首先push ebp:將呼叫方函式站點地址壓進來,呼叫方函式站點地址放入當前函式棧底。再將esp賦給ebp,ebp指向當前函式棧底,sub esp 4Ch為當前函式開闢棧幀,rep sots esp<->ebp當前函式棧幀初始化為0;
//②準備工作做完之後,指向當前函式第一行指令。若該類中有虛擬函式,生成的物件前4個位元組有vfptr,會在函式棧幀開闢完之後將vfptr寫入vftable,才執行函式第一行指令。vfptr <—>&Base::vftable;
//③每次層建構函式都會執行剛才的步驟,派生類中會將vfptr<—>&Derive::vftable。

構造new Derive() : 首先呼叫基類的構造,構造基類部分,然後呼叫派生類構造,構造派生類部分, 基類和派生類中各會有一個vfptr;

當我們new一個Derive物件時,首先呼叫基類構造,基類構造首先會將基類的vftable寫入vfptr,再呼叫clear會將虛擬函式的值清為0;再呼叫派生類的建構函式,派生類建構函式壓完棧初始化後會將&Derive::vftable的地址寫入到虛擬函式指標中。當我們用指標呼叫show()vfptr是有效的,能夠從虛擬函式表中取出來派生類重寫的show(),因此執行成功。
在這裡插入圖片描述

相關文章