天天看點

類和對象·引入

類和對象·引入
你好,我是安然無虞。

文章目錄

  • ​​自學網站​​
  • ​​寫在前面​​
  • ​​面向過程和面向對象的初步認識​​
  • ​​類的引入​​
  • ​​類的定義​​
  • ​​類的通路限定符及封裝​​
  • ​​通路限定符​​
  • ​​面試題​​
  • ​​封裝​​
  • ​​類的作用域​​
  • ​​類的執行個體化​​
  • ​​類對象模型​​
  • ​​如何計算類對象的大小?​​
  • ​​類對象的存儲方式猜測​​
  • ​​結構體記憶體對齊規則​​
  • ​​結構體傳參​​
  • ​​this指針​​
  • ​​this指針的引出​​
  • ​​this指針的特性​​
  • ​​大廠面試真題​​

自學網站

推薦給老鐵們兩款學習網站:

面試利器&算法學習:​​​牛客網​​​ 風趣幽默的學人工智能:​​人工智能學習​​ ​

寫在前面

前面的C++入門知識已經講解完畢了,重難點部分需要及時消化哦,現在我們開始類和對象的學習,老鐵請放心,這部分内容雖然有點多,也屬于重點,但是不算太難,跟着我肯定都能掌握的,加油吧少年們。

面向過程和面向對象的初步認識

我們知道C語言是面向過程的,關注點在于過程,分析出求解問題的步驟,通過函數調用逐漸解決問題;

C++是面向對象的,關注點在于對象,将一件事拆分成不同的對象,靠對象之間的互動完成。

就好比我們生活中的外賣系統:

用面向過程來說就是:上架貨品、點外賣、通知商家、配置設定騎手、派送、點評等,注重的是過程本身;而用面向對象來說就是:商家、使用者和騎手,注重的是對象本身。

類和對象·引入

類的引入

C語言中,結構體内隻能定義變量,不過在C++中,結構體内不僅可以定義變量,還可以定義函數。

struct Student
{
  void SetStudent(const char* name, const char* gender, int age)
  {
    strcpy(_name, name);
    strcpy(_gender, gender);
    _age = age;
  }
  void PrintStudent()
  {
    cout << _name << " " << _gender << " " << _age << endl;
  }

  char _name[20];
  char _gender[3];//性别
  int _age;
};

int main()
{
  Student s;
  s.SetStudent("sl", "男", 21);
  s.PrintStudent();
  return 0;
}      

上面結構體的定義,C語言用的是struct,C++用 struct 和 class 都可以,但是C++更喜歡用 class 來代替 struct。因為C++相容C語言 struct 的用法,C++同時對struct進行了更新,把struct更新成了類。什麼是類呢,它有如下兩個特點:

  1. 結構體名稱可以做類型;
  2. 類裡面可以定義函數
struct ListNode//用struct和class都可以,C++更喜歡用class
{
  int val;
  struct ListNode* next;
  //現在的寫法
  ListNode* next;//類名可以直接做類型
};      

類的定義

class className
{
  //類體:由成員函數和成員變量組成
  
};//一定要注意後面的分号      

class為定義類的關鍵字,className為類的名字,{}中為類的主體,注意類定義結束時後面的分号。

類中的元素稱為類的成員:類中的資料稱為類的屬性或者成員變量;類中的函數稱為類的方法或者成員函數。

類的兩種定義方式:

1、聲明和定義全部放在類體中,需要注意:成員函數在類中定義,編譯器可能會将其當成内聯函數處理,是以一般情況下,短小函數可以直接在類裡面定義,長一點的函數聲明和定義分離;

可能這時候有的老鐵會想到C++入門,那時候講到内聯函數聲明和定義不能分離,否則會導緻連結錯誤這個問題。同樣的,如果函數比較長,這時候還在類中定義,類又在.h頭檔案中實作,在其他兩個.cpp源檔案中展開這個.h檔案,會不會出現連結錯誤(重定義)呢?

答案是不會,因為類中如果定義較長的函數,雖然不會當作内聯函數處理,但是它的定義、位址都已經知道了,編譯器不會将它再放到符号表中,是以連結的時候不用去符号表中找。(這個的話了解一下即可)

class Person
{
public:
  //顯示基本資訊
  void PrintPerson()
  {
    cout << _name << " " << _gender << " " << _age << endl;
  }

public:
  char* _name;
  char* _gender;//性别
  int _age;
};      

2、聲明放在.h檔案中,類的定義放在.cpp檔案中。

//聲明放到類的頭檔案person.h中
class Person
{
public:
  void PrintPerson();
  
public:
  char* _name;
  char* _gender;
  int _age;
};      
//定義放到類的實作檔案person.cpp中
#include"person.h"

void Person::PrintPerson()//需要指定類域
{
  cout << _name << " " << _gender << " " << _age << endl;
}      

一般情況下,更期望采用第二種方式。

類的通路限定符及封裝

通路限定符

C++實作封裝的方式:用類将對象的屬性和方法結合在一塊,讓對象更加完善,通過通路權限選擇性的将其接口提供給外部的使用者使用。

類和對象·引入

通路限定符說明:

  1. public 修飾的成員在類外可以直接被通路;
  2. protected 和 private 修飾的成員在類外不能直接被通路;(現階段我們暫且認為 protected 和 private 一樣,等到繼承時再區分)
  3. 通路權限作用域從該通路限定符出現的位置開始直到下一個通路限定符出現時為止;
  4. class 的預設通路權限是 private,struct 的預設通路權限是 public。(因為 struct 要相容C語言)

注意:通路限定符隻在編譯時有用,當資料映射到記憶體後,沒有任何通路限定符上的差別。

面試題

問題:C++中 struct 和 class 的差別是什麼?

解答:C++需要相容C語言,是以C++中 struct 可以當成結構體去使用,另外C++中 struct 還可以用來定義類,和 class 定義類是一樣的,差別在于 struct 的成員預設通路方式是 public,class 的成員預設通路方式是 private。

封裝

【面試題】

面向對象的三大特性:封裝、繼承、多态。

在類和對象階段,我們隻研究了類的封裝特性,那什麼是封裝呢?

封裝:将資料和操作資料的方法進行有機結合,隐藏對象的屬性和實作細節,僅對外公開接口來和對象進行互動。

封裝本質上是一種管理:為什麼這麼說呢,比如現實生活中我們如何管理兵馬俑的?如果什麼都不管,兵馬俑肯定被随意破壞了,是以我們首先要建一座房子把兵馬俑給封裝起來,但還要給别人看到,不能全封裝起來,是以我們開放了售票通道,可以買票突破封裝在合理的監管機制下進去參觀。

類也是這樣,我們使用類中的資料和方法都封裝到了一起,不想讓别人看到,使用 protected/private 把成員封裝起來,開放一些共有的成員函數對成員進行合理的通路。是以說封裝本質上是一種管理。

就拿我們之前學習過的棧這個資料結構來說明這個問題。我們之前是使用C語言來實作棧Stack這個資料結構的,因為C語言資料和方法是分離的,是以我們之前的代碼往往都是這樣的:

struct Stack
{
  int* _a;
  int _top;
  int _capacity;
};

void StackInit(struct Stack* ps)
{
  ps->_a = NULL;
  ps->_top = 0;//或者ps->_top = -1;定義很靈活
  ps->_capacity = 0;
}

void StackPush(struct Stack* ps, int x)
{}

int StackTop(struct Stack* ps)
{}

int main()
{
  struct Stack st;
  StackInit(&st);
  StackPush(&st, 1);
  StackPush(&st, 2);
  printf("%d\n", StackTop(&st));
  //因為資料和方法是分離的,是以還可能存在這兩種寫法,但是極容易出錯
  printf("%d\n", st._a[st._top]);
  printf("%d\n", st._a[st._top - 1]);

  return 0;
}      

如上,用C語言代碼實作Stack時,通路棧頂資料時很容易出現錯誤,因為它的資料和方法是分離的。這樣的話就凸顯出了C++的優勢:

  1. 資料和方法封裝到一起,在類裡面;
  2. 想給你自由通路的設計成公有,不想給你直接通路的設計成私有/保護,一般情況設計類,成員資料都是私有/保護。

用C++代碼實作Stack是這樣的:

class Stack
{
public:
  void Init()
  {}
  void Push(int x)
  {}
  int Top()
  {}

private:
  int* _a;
  int _top;
  int _capacity;
};

int main()
{
  Stack st;
  st.Init();
  st.Push(1);
  st.Push(2);
  printf("%d\n", st.Top());

  //printf("%d\n", st._a[st._top]);直接報錯,編譯通不過
  return 0;
}      

這裡的話老鐵能體會到C語言和C++對于棧頂元素的通路方式不同之處就好,具體細節我後面會詳細說明哦。

類的作用域

類定義了一個新的作用域,叫類域,類的所有成員都在類的作用域中。在類體外定義成員,需要使用 :: 作用域解析符指定成員屬于哪個類域。

//person.h檔案
class Person
{
public:
  void PrintPerson()
  {}
  
private:
  char _name[20];
  char _gender[3];
  int _age;
}      
//person.cpp檔案
//這裡需要指定PrintPerson()屬于person這個類域
//通路限定符限制的是在類外進行通路,在類裡面不受限制
void person::PrintPerson()
{
  cout << _name << " " << _gender << " " << _age << endl;
}      

類的執行個體化

用類類型建立對象的過程,稱之為類的執行個體化。

  1. 類隻是一個模型一樣的東西,限定了類有哪些成員,定義出一個類并沒有配置設定出實際的記憶體空間來存儲它;
  2. 一個類可以執行個體化出多個對象,執行個體化處的對象占用實際的實體空間,存儲類成員變量;
  3. 打個比方,類執行個體化出對象就像現實中使用建築設計圖建造出房子,類就像是設計圖,隻設計出需要什麼東西,但是并沒有實體的建築存在,同樣類也隻是一個設計,執行個體化處的對象才能實際存儲資料,占用實體空間。
類和對象·引入
類和對象·引入

請問:類體中的成員變量是聲明還是定義,如果是聲明,那什麼時候定義呢?

答案是聲明,那變量的聲明和定義的差別是什麼呢?差別在于有沒有開辟空間。打個比方,你要買房,問朋友借5W塊,你朋友答應借給你5W塊,這就好比聲明,支付寶到賬5W塊,就好比定義。

既然類體中的成員變量是聲明,那什麼時候定義呢?答案是在建立對象的時候定義,也就是這一行代碼:

Person man;      

類對象模型

如何計算類對象的大小?

class A
{
public:
  void PrintA()
  {
    cout << _a << endl;
  }

private:
  char _a;
};      

問題:類中既可以有成員變量,有可以有成員函數,那麼一個類的對象中包含了什麼?如何計算一個類的大小?

若要解決這個問題,需要繼續向下學習。

類對象的存儲方式猜測

猜測1:對象中包含類的各個成員

類和對象·引入

缺陷:每個對象中成員變量是不同的,卻是調用同一份成員函數,如果按照這種方式存儲,當一個類建立多個對象時,每個對象中都會儲存一份代碼,相同代碼儲存多次,浪費空間。

比如還是之前的Stack代碼:

類和對象·引入

st1調用Init(),st2調用Init()是不是同一個Init(),是的,是同一個Init(),有沒有必要把成員函數在對象裡面都存一份,答案是沒有必要,因為不同的對象調用的是同一個函數,通過彙編代碼就能看出來。

類和對象·引入

那麼該如何解決呢?猜測2:隻儲存成員變量,成員函數放在公共代碼段

類和對象·引入

是以下面代碼的意義不一樣:

Stack st1;
st1._top;//_top在st1裡面找
st1.Init();//調用Init()不是在對象裡面找,而是到公共代碼段中      

問題:對于上述兩種存儲方式,計算機到底是按照哪種方式來存儲的?

我們通過對下面的不同對象分别擷取大小來分析:

//類中既有成員變量,又有成員函數
class A1
{
public:
  void f1()
  {}

private:
  char _ch;
  int _a;
};

//類中僅有成員函數
class A2
{
public:
  void f2()
  {}
};

//類中什麼都沒有,空類
class A3
{};

int main()
{
  cout << sizeof(A1) << endl;//8
  cout << sizeof(A2) << endl;//1
  cout << sizeof(A3) << endl;//1
  return 0;
}      

可以認為sizeof(A1)和sizeof(A2)一樣大,因為成員函數不在對象裡面。可有老鐵會問了,既然成員函數不再對象裡面,為什麼大小不是0,而且空類的大小也不是0?

可以這樣解釋:如果大小是0,那麼A2類型定義對象aa時:

A2 aa;
cout << &a << endl;      

aa的位址是多少呢?是以這1個位元組不是為了存儲有效資料,而是為了表示對象存在過。

總結:一個類的大小,實際就是該類中“成員變量”之和,當然也要進行記憶體對齊,注意空類的大小,空類比較特殊,編譯器給了空類1個位元組來唯一辨別這個類,沒有成員變量的類對象,編譯器會給他們配置設定1個位元組用于表示對象存在過。

結構體記憶體對齊規則

現在我們深入探讨一個問題:計算結構體的大小。這也是一個特别熱門的考點:結構體記憶體對齊。

學過C語言的老鐵快來檢測自己吧。

練習1:

struct s1
{
  char c1;
  int i;
  char c2;
};
printf("%d\n", sizeof(struct s1));      

練習2:

struct s2
{
  char c1;
  char c2;
  int i;
};
printf("%d\n", sizeof(struct s2));      

練習3:

struct s3
{
  double d;
  char c;
  int i;
};
printf("%d\n", sizeof(struct s3));      

練習4:結構體嵌套問題

struct s3
{
  double d;
  char c;
  int i;
};
printf("%d\n", sizeof(struct s3));

struct s4
{
  char c1;
  struct s3 s;
  double d;
};
printf("%d\n", sizeof(struct s4));      

OK,請老鐵結合下面的方法計算。

那麼如何計算結構體的大小呢?

首先得掌握結構體的對齊規則:

  1. 第一個成員在與結構體變量偏移量為0的位址處;
  2. 其他成員要對齊到某個數字(對齊數)的整數倍的位址處;對齊數 = 編譯器預設的一個對齊數與該成員大小的較小值(VS預設對齊數是8)
  3. 結構體的總大小為最大對齊數(每個成員變量都有自己的對齊數)的整數倍;
  4. 如果是嵌套了結構體的情況,嵌套的結構體對齊到自己最大對齊數的整數倍處,結構體的整體大小就是所有最大對齊數(含嵌套結構體的對齊數)的整數倍。

注意:

關于預設對齊數的概念,在VS下預設是8,在Linux下沒有預設對齊數的概念,對齊數就是成員自身的大小。

這裡可能就有老鐵會問了,為什麼要存在記憶體對齊呢?

基本上有兩點原因:

1、平台原因(移植原因)

不是所有的硬體平台都能通路任意位址上的任意資料的;某些硬體平台隻能在某些位址處取某些特定類型的資料,否則抛出硬體異常。

2、性能原因

資料結構(尤其是棧)應該盡可能地在自然邊界上對齊。原因在于,為了通路未對齊的記憶體,處理器需要作兩次記憶體通路,而對齊的記憶體通路時近一次即可。

總體來說:

結構體的記憶體對齊是拿空間換時間的做法。

請看下面:

struct S1
{
   char c1;
   int i;
   char c2;
};
struct S2
{
   char c1;
   char c2;
   int i;
};      
類和對象·引入
類和對象·引入

S1和S2類型的成員一模一樣,但是S1和S2所占空間的大小有一些的差別。

結構體傳參

struct S 
{
   int data[1000];
   int num;
};
struct S s = {{1,2,3,4}, 1000};
//結構體傳參
void print1(struct S s) 
{
   printf("%d\n", s.num);
}
//結構體位址傳參
void print2(struct S* ps) 
{
   printf("%d\n", ps->num);
}
int main()
{
   print1(s);  //傳結構體
   print2(&s); //傳位址
   return 0; 
}      

上面的 print1 和 print2 函數哪個好些?

答案是print2函數好些。

原因:

函數傳參的時候,參數是需要壓棧,會有時間和空間上的系統開銷。如果傳遞一個結構體對象的時候,結構體過大,參數壓棧的的系統開銷比較大,是以會導緻性能的下降。

this指針

this指針的引出

我們先來定義一個日期類Date:

class Date
{
public:
  void SetDate(int year, int month, int day)
  {
    _year = year;
    _month = month;
    _day = day;
  }
  void Display()
  {
    cout << _year << " " << _month << " " << _day << endl;
  }

private:
  int _year;
  int _month;
  int _day;
};

int main()
{
  Date d1, d2;
  d1.SetDate(2022, 8, 24);
  d2.SetDate(2022, 9, 3);
  d1.Display();
  d2.Display();
  return 0;
}      

通過彙編代碼可以看到d1和d2調用的是同一個Display(),那為什麼列印的值不一樣呢?

類和對象·引入

聰明的老鐵很快就能想到this指針,沒錯,調用成員函數Display()的地方會編譯器被處理成:

d1.Display(&d1);
d2.Display(&d2);      

同樣的,Display()也會被編譯器隐含的處理成這個樣子:

void Display(Date* const this)//this指針不可以修改
{
  cout << this->_year << " " << this->_month << " " << this->_day << endl;
}      

形參和實參部分不能顯示的寫出來,因為是編譯器隐含的處理的,我們不能搶了編譯器的飯碗。

this指針的特性

1、this指針的類型:類類型* const;

2、隻能在成員函數的内部使用;(注意)

3、this指針本質上其實是一個成員函數的形參,是對象調用成員函數時,将對象位址作為實參傳遞給this形參,是以對象中不存儲this指針;

4、this指針是成員函數第一個隐含的指針形參,一般情況由編譯器通過ecx寄存器自動傳遞,不需要使用者傳遞。

類和對象·引入

大廠面試真題

1、下面程式編譯運作結果是什麼?

A.編譯報錯

B.運作崩潰

C.正常運作

class A
{
public:
  void show()
  {
    cout << "show()" << endl;
  }

public:
  int _a;
};

int main()
{
  A* p = nullptr;
  p->show();
  p->_a;
  return 0;
}      

解答:這道題打死都不能選編譯報錯,因為編譯時是查不出空指針錯誤的,所有的空指針、野指針問題都是在運作時暴露出來的。

這道題的答案是正常運作,為什麼沒有崩潰呢?下面我們看調試後的彙編代碼:

類和對象·引入
p->show();//對象裡面隻有成員變量,是以并沒有解引用,而是調用這個函數      

那這行代碼為什麼沒有崩潰呢?

p->_a;      

從彙編代碼中你就能發現,編譯器優化後直接跳過這行代碼什麼都沒做,但是如果将代碼改成這樣子就會運作崩潰了:

class A
{
public:
  void show()
  {
    cout << "show()" << endl;
  }

public:
  int _a;
};

int main()
{
  A* p = nullptr;
  p->show();
  p->_a = 10;
  return 0;
}      

再看看現在的彙編代碼:

類和對象·引入

其實這就發生解引用了,因為不能通路空指針,是以報錯,上面的代碼之是以沒有報錯是因為什麼都沒做,這裡不一樣。

class A
{
public:
  void PrintA()
  {
    //編譯器隐含處理成:cout << this->a << endl;
    cout << _a << endl;//這道題是在這裡崩潰的
  }

private:
  int _a;
};

int main()
{
  A* p = nullptr;
  p->PrintA();

  return 0;
}      

繼續閱讀