天天看點

類和對象·預設成員函數

類和對象·預設成員函數
你好,我是安然無虞。

文章目錄

  • ​​自學網站​​
  • ​​寫在前面​​
  • ​​類的6個預設成員函數​​
  • ​​構造函數​​
  • ​​概念​​
  • ​​特性​​
  • ​​析構函數​​
  • ​​概念​​
  • ​​特性​​
  • ​​拷貝構造函數​​
  • ​​概念​​
  • ​​特性​​
  • ​​指派運算符重載​​
  • ​​運算符重載​​
  • ​​指派運算符重載​​
  • ​​大廠面試真題​​

寫在前面

上一節我們知道了類和對象的基本概念,這一節我們詳細說說類的6個預設成員函數。

類的6個預設成員函數

如果一個類中什麼都沒有,我們稱之為空類。空類中真的什麼都沒有嗎?并不是的,任何一個類在我們不寫的情況下,都會自動生成下面6個預設成員函數。

class 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, 25);
  d1.Display();

  d2.SetDate(2022, 9, 3);
  d2.Display();
  return 0;
}      

對于Date類,可以通過SetDate公有的方法給對象設定内容,但是如果每次建立對象都調用該方法設定資訊,未免有點麻煩,那能否在對象建立時,就直接将資訊設定進去呢?

構造函數是一個特殊的成員函數,名字和類名相同,建立類類型對象時由編譯器自動調用,保證每個資料成員都有一個合适的初始值,并且在對象的生命周期内隻調用一次。

特性

構造函數是特殊的成員函數,需要注意的是,構造函數雖然名字叫構造,但是構造函數的主要任務并不是開空間建立對象,而是初始化對象。

其特征如下:

  1. 函數名與類名相同;
  2. 無傳回值;
  3. 對象執行個體化時編譯器自動調用對應的構造函數;
  4. 構造函數可以重載。
class Date
{
public:
  //1.無參構造函數
  Date()
  {}
  //2.帶參構造函數(與上面的無參構造函數形成重載)
  Date(int year, int month, int day)
  {
    _year = year;
    _month = month;
    _day = day;
  }

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

int main()
{
  Date d1;//調用無參構造函數
  Date d2(2022, 8, 25);//調用帶參的構造函數

  //注意:如果通過無參構造函數建立對象時,對象後面不用跟括号,否則就變成了函數聲明
  //以下代碼的函數:聲明了d3函數,該函數無參,傳回一個日期類型的對象 
  //Date d3();這樣寫是錯誤的
  return 0;
}      
  1. 如果類中沒有顯示定義構造函數,則C++編譯器會自動生成一個無參的預設構造函數,一旦使用者顯示定義,編譯器将不再自動生成。
class Date
{
public:
  如果使用者顯示定義了構造函數,編譯器将不再生成
  //Date(int year, int month, int day)
  //{
  //  _year = year;
  //  _month = month;
  //  _day = day;
  //}

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

int main()
{
  //沒有定義構造函數,對象也可以建立成功,是以此處調用的是編譯器自動生成的預設構造函數
  Date d;
  return 0;
}      
類和對象·預設成員函數

但是通過監視視窗發現,d對象中的成員變量依舊是随機值,這是為什麼呢,别着急老鐵,後面會詳細說明哦。

  1. 無參的構造函數和全預設的構造函數都稱為預設構造函數,并且預設構造函數隻能有一個。注意:無參構造函數、全預設構造函數、我們沒寫編譯器自動生成的構造函數,都可以認為是預設成員函數(不傳參就可以直接調用的)。
class Date
{
public:
  /*Date()
  {
    _year = 2022;
    _month = 8;
    _day = 25;
  }*/

  Date(int year = 2022, int month = 8, int day = 25)
  {
    _year = year;
    _month = month;
    _day = day;
  }

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

int main()
{
  Date d;
  return 0;
}      
  1. 關于編譯器預設生成的成員函數,很多老鐵都會有疑問:在我們不實作構造函數的情況下,編譯器會自動生成預設的構造函數。但是看起來預設生成的構造函數又沒什麼用,因為d對象調用了編譯器自動生成的預設構造函數,但是d對象的成員變量_year/_month/_day,依舊是随機值,也就是說,這裡的編譯期自動生成的預設構造函數并沒有什麼用?

    解答:C++把類型分為内置類型(基本類型)和自定義類型。内置類型就是文法已經定義好的類型,如char, int…;自定義類型就是我們使用class/struct/union自己定義的類型。看看下面一段程式,你會發現編譯器生成預設的構造函數會對自定義類型成員 _t 調用它的預設成員函數。

class Time
{
public:
  Time()
  {
    cout << "Time()" << endl;
    _hour = 0;
    _minute = 0;
    _second = 0;
  }

private:
  int _hour;
  int _minute;
  int _second;
};

class Date
{
private:
  //内置類型
  int _year;
  int _month;
  int _day;

  //自定義類型
  Time _t;
};

int main()
{
  Date d;
  return 0;
}      
類和對象·預設成員函數

從中就可以看出,編譯器預設生成的構造函數對内置類型不做處理,對于自定義類型會調用它的預設構造函數處理。 這句話很重要,請細品!

再看下面一段代碼:

class Stack
{
public:
  Stack()
  {
    _a = nullptr;
    _top = _capacity = 0;
  }
private:
  int* _a;
  int _top;
  int _capacity;
};

class myQueue
{
public:
  void Push(int x)
  {}
  int pop()
  {}

private:
  //全是自定義類型,編譯器預設生成的構造函數就夠用了
  Stack st1;
  Stack st2;
};      

總結:

如果一個類中的成員全是自定義類型,并且這些成員都提供了預設成員函數,我們就可以用編譯器預設生成的構造函數,如果有内置類型的成員,或者需要顯示傳參初始化,那都需要自己實作構造函數。(一般情況下,C++類都需要自己實作構造函數)

當然,C++11關于編譯器自己生成的預設構造函數對内置類型不作處理,其實打了更新檔,不過需要支援C++11的編譯器才能用,打的更新檔是這樣的:

class myQueue
{
public:
  void Push(int x)
  {}
  int pop()
  {}

private:
  //C++11打的更新檔,針對編譯器自己生成的預設構造函數對内置類型不作處理的問題
  //這裡的0給的是預設值,留給編譯器自己生成的預設構造函數用
  //大家了解一下即可
  int _size = 0;
  
  Stack st1;
  Stack st2;
};      
  1. 成員變量的命名風格
//看看下面的寫法是不是很僵硬?
class Date
{
public:
  Date(int year)
  {
    //這裡的year到底是成員變量,還是函數形參?
    year = year;
  }

private:
  int year;
};

//是以一般我們建議下面這種寫法:
class Date
{
public:
  Date(int year)
  {
    _year = year;
  }
private:
  int _year;
};

//或者是這樣的寫法:
class Date
{
public:
  Date(int year)
  {
    m_year = year;
  }

private:
  int m_year;
};      

其他的方式也是可以的,主要看公司要求,一般都是在成員變量前面加個字首或者字尾辨別符區分就行。

析構函數

概念

前面通過構造函數的學習,我們知道了一個對象是怎麼來的,那一個對象又是怎麼沒的呢?

析構函數:與構造函數相反,析構函數不是完成對象的銷毀,局部對象銷毀工作是由編譯器完成的。而對象在銷毀時會自動調用析構函數,完成類的一些資源清理工作。

特性

析構函數是特殊的成員函數。

其特征如下:

  1. 析構函數名是在類名前加上字元~;
  2. 無參數無傳回值;
  3. 一個類有且隻有一個析構函數,若未顯示定義,編譯器會自動生成預設的析構函數,對于内置類型不作處理,自定義類型會調用它自己的析構函數;
  4. 對象生命周期結束時,C++編譯器自動調用析構函數。
//拿之前的順序表為例:
class SeqList
{
public:
  SeqList(int capacity = 10)
  {
    _a = (int*)malloc(capacity * sizeof(int));
    assert(_a);

    _size = 0;
    _capacity = capacity;
  }
  ~SeqList()
  {
    if (_a)
    {
      free(_a);//釋放堆上的空間
      _a = nullptr;
      _size = 0;
      _capacity = 0;
    }
  }

private:
  int* _a;
  size_t _size;
  size_t _capacity;
};      

還有一點需要注意的是,在棧裡面定義對象,析構順序和構造順序是相反的,而且自定義類型先析構。不信,你調試下面代碼即可發現:

class Stack
{
public:
  Stack(int capacity = 10)
  {
    _a = (int*)malloc(capacity * sizeof(int));
    assert(_a);
    _top = 0;
    _capacity = capacity;
  }
  ~Stack()
  {
    free(_a);
    _a = nullptr;
    _top = _capacity = 0;
  }

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

int main()
{
  //在棧裡面定義對象,析構順序和構造順序是相反的
  Stack st1(1);
  Stack st2(2);
  return 0;
}      
  1. 關于編譯器自動生成的析構函數,能否完成一些事情呢?下面的程式我們會看到,編譯器生成的預設析構函數,會對自定義類型成員調用它的析構函數。
class String
{
public:
  String(const char* str = "sl")
  {
    _str = (char*)malloc(strlen(str) + 1);
    strcpy(_str, str);
  }
  ~String()
  {
    cout << "~String()" << endl;
    free(_str);
  }

private:
  char* _str;
};

class Person
{
private:
  String _name;
  int _age;
};      

拷貝構造函數

拷貝構造函數是特殊的構造函數。

概念

在現實生活中,可能存在與你十分相像的人,我們稱之為雙胞胎。

類和對象·預設成員函數

那我們在建立對象的時候,能否建立一個與另一個對象一模一樣的對象呢?

這就要談談拷貝構造函數:隻有單個形參,該形參是對本類類型對象的引用(一般常用const修飾),在用已存在的類類型對象建立新對象時由編譯器自動調用。

特性

拷貝構造函數也是特殊的成員函數,其特征如下:

  1. 拷貝構造函數是構造函數的一個重載形式;
  2. 拷貝構造函數的參數隻有一個而且必須使用引用傳參,如果使用傳值方式會引發無窮遞歸調用;
class Date
{
public:
  Date(int year = 2022, int month = 8, int day = 25)
  {
    _year = year;
    _month = month;
    _day = day;
  }
  //之是以加上const,是為了保護d
  Date(const Date& d)//注意必須是引用傳參
  {
    _year = d._year;
    _month = d._month;
    _day = d._day;
  }
  
private:
  int _year;
  int _month;
  int _day;
};      

為什麼說拷貝構造函數一定要使用引用傳參呢?請繼續往下看:

//同類型的對象傳值傳參是會調用拷貝構造的
void Func(Date d)
{}

int main()
{
  Date d1(2022, 8, 25);
  Func(d1);//調用拷貝構造
}      

内置類型是直接拷貝,自定義類型調用拷貝構造。自定義類型對象,拷貝初始化規定要調用拷貝構造完成。

類和對象·預設成員函數

加上引用就不會出現上述情況了,因為成了别名。

3.若未顯示定義,編譯器生成預設的拷貝構造函數。預設生成的拷貝構造函數對象按記憶體存儲位元組序完成拷貝,這種拷貝我們叫淺拷貝,或者值拷貝。(這點與編譯器預設生成的構造函數和析構函數不同,這二者對于内置類型是不作處理的)

class Date
{
public:
  Date(int year = 2022, int month = 8, int day = 25)
  {
    _year = year;
    _month = month;
    _day = day;
  }

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

int main()
{
  Date d1;

  //這裡d2調用預設拷貝構造函數完成拷貝,d2和d1的值也是一樣的
  Date d2(d1);
  return 0;
}      

我們不寫編譯器會預設生成一個拷貝構造函數:

  • 内置類型的成員會完成值拷貝,淺拷貝;
  • 自定義類型的成員,去調用這個成員的拷貝構造函數。

結論:

一般的類,自己生成的拷貝構造函數就可以用了,隻有像Stack這樣的類,自己直接管理資源嗎,需要自己實作深拷貝,這個我們後面說。

  1. 那麼編譯器預設生成的拷貝構造函數已經可以完成位元組序的值拷貝了,我們還需要自己實作嗎?當然像Date這樣的類是沒有必要的,那下面的類的?我們來驗證一下:
class String
{
public:
  String(const char* str = "sl")
  {
    _str = (char*)malloc(strlen(str) + 1);
    strcpy(_str, str);
  }
  ~String()
  {
    cout << "~String()" << endl;
    free(_str);
  }

private:
  char* _str;
};

int main()
{
  String s1("hello");
  String s2(s1);
  return 0;
}      

上面的代碼,如果将自己實作的拷貝構造函數注釋掉,采用編譯器預設生成的拷貝構造的話,我們發現程式會崩潰,這就需要我們以後講的深拷貝去解決。

這裡還以之前的Stack作為例子,實作拷貝構造如下:

class Stack
{
public:
  Stack(int capacity = 10)
  {
    _a = (int*)malloc(capacity * sizeof(int));
    assert(_a);
    _top = 0;
    _capacity = capacity;
  }
  ~Stack()
  {
    free(_a);
    _a = nullptr;
    _top = _capacity = 0;
  }
  //實作成值拷貝,這裡是錯誤的
  Stack(const Stack& st)
  {
    _a = st._a;
    _top = st._top;
    _capacity = st._capacity;
  }

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

int main()
{
  Stack st1;
  Stack st2(st1);
  return 0;
}      

注意:上面的_a是int*,屬于整型指針類型,拷貝的是指針本身,而指針又是位址,是以是将這個位址拷貝給它,故而二者指向的是同一塊位址。但是如果_a類型是數組類型,比如int _a[10]這樣是可以的,因為數組也被認為是内置類型。可能你沒聽明白,沒關系,請繼續看:

類和對象·預設成員函數

總結淺拷貝的問題:

  • 指向同一塊空間,修改資料會互相影響;
  • 這塊空間析構時會釋放兩次,程式會崩潰。

解決方案:

要自己實作拷貝構造,完成深拷貝。

指派運算符重載

運算符重載

C++為了增強代碼的可讀性引入了運算符重載(因為内置類型可以直接使用各種運算符,而自定義類型卻不能直接使用各種運算符,是以為了自定義類型像内置類型一樣可以直接使用各種運算符,C++便引入了運算符重載規則)。運算符重載是具有特殊函數名的函數,也具有其傳回值類型,函數名字以及參數清單,而且其傳回值類型和參數清單與普通的函數類似。

函數名:operator運算符(關鍵字operator後面接需要重載的運算符符号)

參數:運算符操作數

傳回值:運算符運算後的結果

函數原型:傳回值類型 operator操作符(參數清單)

注意:

  • 不能通過連接配接其他符号來建立新的操作符,比如operator@;
  • 重載運算符必須有一個類類型或者枚舉類型的操作數;
  • 用于内置類型的操作符,其含義不能改變,例如:内置的整型+,不能改變其含義;
  • 作為類成員函數重載時,其形參看起來比操作數數目少1,因為成員函數的第一個參數為隐藏的this;
  • .* :: sizeof ?: . 注意以上5個運算符不能重載。(這個經常在筆試選擇題中出現)
class Date
{
public:
  Date(int year = 2022, int month = 8, int day = 27)
  {
    _year = year;
    _month = month;
    _day = day;
  }

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

//如果将運算符重載成全局的,就需要成員變量是共有的,這樣的話封裝性如何保證呢?
//可以用後面學的友元解決,或者幹脆重載成成員函數
bool operator==(const Date& d1, const Date& d2)
{
  return d1._year == d2._year
    && d1._month == d2._month
    && d1._day == d2._day;
}      

正确寫法如下:

class Date
{
public:
  Date(int year = 2022, int month = 8, int day = 27)
  {
    _year = year;
    _month = month;
    _day = day;
  }
  //這裡需要注意的是成員函數第一個形參是this指針(左操作數是this指向的調用函數的對象)
  bool operator==(const Date& d2)
  {
    return _year == d2._year
      && _month == d2._month
      && _day == d2._day;
  }

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

int main()
{
  Date d1;
  Date d2(d1);
  //if(d1 == d2)編譯器會自動處理成對應的重載運算符調用if(d1.operator==(&d1,d2))
  if (d1 == d2)
    cout << "d1 == d2" << endl;
  return 0;
}      

指派運算符重載

class Date
{
public:
  Date(int year = 2022, int month = 8, int day = 27)
  {
    _year = year;
    _month = month;
    _day = day;
  }
  Date(const Date& d)
  {
    _year = d._year;
    _month = d._month;
    _day = d._day;
  }
  //如果傳回的對象沒有在函數結束時被銷毀就傳回其引用
  Date& operator=(const Date& d)
  {
    if (this != &d)//注意這裡是原生指針的比較
    {
      _year = d._year;
      _month = d._month;
      _day = d._day;
    }
    return *this;
  }

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

指派運算符主要有5點:

  • 參數類型:const T&,傳遞引用可以提高傳參效率;
  • 傳回值類型:T&,傳回引用可以提高傳回的效率,有傳回值目的是為了支援連續指派;
  • 檢測是否自己給自己指派,提高效率;
  • 傳回*this;
  • 一個類如果沒有顯示定義指派運算符重載,編譯器也會自動生成一個,完成對象按照位元組序的值拷貝。
class Date
{
public:
  Date(int year = 2022, int month = 8, int day = 27)
  {
    _year = year;
    _month = month;
    _day = day;
  }

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

int main()
{
  Date d1;
  Date d2(2022, 10, 1);
  Date d3(d1);//拷貝構造,一個已存在的對象去初始化另一個要建立的對象
  d1 = d2;//指派重載/複制拷貝,兩個已經存在的對象之間的指派
}      
類和對象·預設成員函數

注意:

拷貝構造和指派重載的差別在于拷貝構造是一個已存在的對象去初始化另一個要建立的對象,而指派重載是兩個已存在的對象之間的指派。

還有一個問題是:編譯器預設生成的指派重載函數已經可以完成位元組序的值拷貝了,我們還需要自己實作嗎?當然像日期類這樣的是沒有必要的,那麼下面的類呢?

//發現程式崩潰了,需要我們以後講的深拷貝去處理
class String
{
public:
  String(const char* str = "sl")
  {
    _str = (char*)malloc(sizeof(str) + 1);
    strcpy(_str, str);
  }
  ~String()
  {
    free(_str);
  }
  
private:
  char* _str;
};

int main()
{
  String s1("hello");
  String s2("cplusplus");

  s1 = s2;
  return 0;
}      

大廠面試真題

1、下列關于構造函數的描述正确的是( )

A.構造函數可以聲明傳回類型

B.構造函數不可以用private修飾

C.構造函數必須與類名相同

D.構造函數不能帶參數

解析:構造函數不能有傳回值,包括void類型也不行;構造函數可以是私有的,隻是這樣之後就不能直接執行個體化對象;構造函數不光可以帶參數,還可以有多個構造函數構成重載。

2、在函數F中,本地變量a和b的構造函數(constructor)和析構函數(destructor)的調用順序是: ( )

;
Class B;

void F() 
{
    A a;
    B b;
}      

解析:構造順序是按照語句的順序進行構造,析構是按照構造的相反順序進行析構。

3、設已經有A,B,C,D4個類的定義,程式中A,B,C,D析構函數調用順序為?( )

;
int main()
{
    A a;
    B b;
    static D d;
    return 0;
}      

解析:

  1. 類的析構函數調用一般按照構造函數調用的相反順序進行的,但是要注意static對象的存在, 因為static改變了對象的生生命周期,需要等待程式結束時才會析構釋放對象;
  2. 全局對象先于局部對象進行構造;
  3. 局部對象按照出現的順序進行構造,無論是否為static;
  4. 是以構造的順序為 c a b d;
  5. 析構的順序按照構造的相反順序析構,隻需注意static改變對象的生命周期之後,會放在局部對象之後進行析構;
  6. 是以析構順序為B A D C。