深拷貝與淺拷貝、值語義與引用語義/對象語義 ——以C++和Python為例
值語義與引用語義(對象語義)
本小節參考自:https://www.cnblogs.com/Solstice/archive/2011/08/16/2141515.html
概念
在任何編程語言中,區分深淺拷貝的關鍵都是要區分值語義和引用語義(對象語義)。
值語義(value sematics)指的是對象的拷貝與原對象是獨立的、無關的,就像拷貝 int 一樣。C++ 的內置類型(bool/int/double/char)都是值語義,標準庫里的 complex<> 、pair<>、vector<>、map<>、string 等等類型也都是值語意,拷貝之后就與原對象脫離關系。Java 語言的 primitive types 也是值語義。
與值語義對應的是“對象語義/object sematics”,或者叫做引用語義(reference sematics)。對象語義指的是面向對象意義下的對象,對象拷貝是禁止的。例如 muduo 里的 Thread 是對象語義,拷貝 Thread 是無意義的,也是被禁止的:因為 Thread 代表線程,拷貝一個 Thread 對象并不能讓系統增加一個一模一樣的線程。Java 里邊的 class 對象都是對象語義/引用語義。
生命期
值語義的一個巨大好處是生命期管理很簡單,就跟 int 一樣——你不需要操心 int 的生命期。值語義的對象要么是 stack object,或者直接作為其他 object 的成員,因此我們不用擔心它的生命期(一個函數使用自己stack上的對象,一個成員函數使用自己的數據成員對象)。
相反,對象語義的 object 由于不能拷貝,我們只能通過指針或引用來使用它。一旦使用指針和引用來操作對象,那么就要擔心所指的對象是否已被釋放,這一度是 C++ 程序 bug 的一大來源。此外,由于 C++ 只能通過指針或引用來獲得多態性,那么在C++里從事基于繼承和多態的面向對象編程有其本質的困難——資源管理。
C++與標準庫中的值語義
C++ 的 class 本質上是值語義的,這才會出現 object slicing 這種語言獨有的問題,也才會需要程序員注意 pass-by-value 和 pass-by-const-reference 的取舍。在其他面向對象編程語言中,這都不需要費腦筋。
值語義是C++語言的三大約束(與C兼容,零開銷,值語義)之一,C++ 的設計初衷是讓用戶定義的類型(class)能像內置類型(int)一樣工作,具有同等的地位。為此C++做了以下設計(妥協):
- class 的 layout 與 C struct 一樣,沒有額外的開銷。定義一個“只包含一個 int 成員的 class ”的對象開銷和定義一個 int 一樣。
- 甚至 class data member 都默認是 uninitialized,因為函數局部的 int 是 uninitialized。
- class 可以在 stack 上創建,也可以在 heap 上創建。因為 int 可以是 stack variable。
- class 的數組就是一個個 class 對象挨著,沒有額外的 indirection。因為 int 數組就是這樣。
- 編譯器會為 class 默認生成 copy constructor 和 assignment operators。其他語言沒有 copy constructor 一說,也不允許重載 assignment operator。C++ 的對象默認是可以拷貝的,這是一個尷尬的特性。
- 當 class type 傳入函數時,默認是 make a copy (除非參數聲明為 reference)。因為把 int 傳入函數時是 make a copy。
- 當函數返回一個 class type 時,只能通過 make a copy(C++ 不得不定義 RVO 來解決性能問題)。因為函數返回 int 時是 make a copy。
- 以 class type 為成員時,數據成員是嵌入的。例如 pair<complex<double>, size_t> 的 layout 就是 complex<double> 挨著 size_t。
C++ 要求凡是能放入標準容器的類型必須具有值語義。準確地說:type 必須是 SGIAssignable concept 的 model。但是,由 于C++ 編譯器會為 class 默認提供 copy constructor 和 assignment operator,因此除非明確禁止,否則 class 總是可以作為標準庫的元素類型——盡管程序可以編譯通過,但是隱藏了資源管理方面的 bug。
因此,在寫一個 class 的時候,先讓它繼承 boost::noncopyable,幾乎總是正確的。
在現代 C++ 中,一般不需要自己編寫 copy constructor 或 assignment operator,因為只要每個數據成員都具有值語義的話,編譯器自動生成的 member-wise copying&assigning 就能正常工作;如果以 smart ptr 為成員來持有其他對象,那么就能自動啟用或禁用 copying&assigning。例外:編寫 HashMap 這類底層庫時還是需要自己實現 copy control。
這些設計帶來了性能上的好處,原因是 memory locality。
個人覺得是這樣(在學習,有錯誤請指出):
基本數據類型 | 自定義class | |
---|---|---|
C++ | 值語義 | 值語義 |
Java | 值語義 | 引用語義 |
Python | 引用語義 | 引用語義 |
當然這都是默認情況下,具體情況具體需求可以用深/淺拷貝來處理。
另外,類的某個成員變量是值語義/引用語義與這個類本身是值語義/引用語義無關。
C++的另一個麻煩之處在于不支持自動垃圾回收,所以要程序員自己小心地處理生命周期問題。
C++中的深淺拷貝
C++中類的拷貝控制
首先我們簡單地提一下C++中的拷貝控制這件事情。當我們定義一個類的時候,為了讓我們定義的類類型像內置類型(char,int,double等)一樣好用,我們通常需要考下面幾件事:
Q1:用這個類的對象去初始化另一個同類型的對象。
Q2:將這個類的對象賦值給另一個同類型的對象。
Q3:讓這個類的對象有生命周期,比如局部對象在代碼部結束的時候,需要銷毀這個對象。
因此C++就定義了5種拷貝控制操作,其中2個移動操作是C++11標準新加入的特性:
拷貝構造函數(copy constructor)
移動構造函數(move constructor)(C++11)
拷貝賦值運算符(copy-assignment operator)
移動賦值運算符(move-assignment operator)(C++11)
析構函數 (destructor)
前兩個構造函數發生在Q1時,中間兩個賦值運算符發生在Q2時,而析構函數則負責類對象的銷毀。
但是對初學者來說,既是福音也是災難的是,如果我們沒有在定義的類里面定義這些控制操作符,編譯器會自動的為我們提供一個默認的版本。這有時候看起來是好事,但是編譯器不是萬能的,它的行為在很多時候并不是我們想要的。
所以,在實現拷貝控制操作中,最困難的地方是認識到什么時候需要定義這些操作。
拷貝控制又是一個大的話題,為了弄明白深淺拷貝,這里我們只需要認識到拷貝構造函數和拷貝賦值運算符:它們是一個在類的對象發生將某個對象賦值給另一個同類的對象時會被用到的拷貝控制。編譯器提供了它們的默認實現,但是在某些情況下,默認的實現并不能很好地工作。
無指針的類
上面已經介紹過,在C++中主要是值語義。首先考慮這樣一個類:
class Foo {
private:int m_a;int m_b;
public:Foo(): m_a(0), m_b(0){ }Foo(int a, int b): m_a(a), m_b(b){ }
};
在這個類中,只有值語義的成員 m_a
和 m_b
。
如果我們要拷貝這個類的一個對象如:
int main(){Foo obj1(3, 5);Foo obj2 = obj1;std::cout << &obj1 << std::endl;std::cout << &obj2 << std::endl;return 0;
}
此時會調用編譯器默認的拷貝構造函數,即淺拷貝,就是簡單地將對象 obj1
內的成員直接照模照樣復制一份,放到新的對象 obj2
中。注意,由于默認提供了拷貝構造函數和賦值運算符,C++中的對象都是值語義的。從而,這樣的賦值操作是會新建一個對象,而非增加一個指向原對象 obj1
的引用(這與Java和Python中不同)。這可以通過查看兩個對象的地址得到驗證,輸出:
0x7ffffaa2ffa0
0x7ffffaa2ff98
二者地址不同。
OK,so far, so good. 這時深淺拷貝其實是一樣的,因為類內沒有指針類型的成員。淺拷貝(編譯器提供的默認拷貝構造函數)就可以工作的很好,不需要我們做什么調整。但是,如果類內包含指針類型的成員,問題就來了。
含有指針的類
當類成員中含有指針類型時,情況就大不相同了,考慮下面的類:
#include <iostream>class Bar {
private:int m_a;int* m_p;
public:Bar(): m_a(0), m_p(nullptr){ }Bar(int a, int* p): m_a(a), m_p(p){ }// Bar(const Bar &bar) { // 自己重寫拷貝構造函數,深拷貝// this->m_a = bar.m_a;// this->m_p = new int(*(bar.m_p));// }void print_member() {std::cout << m_a << ',' << *m_p << std::endl;}void change_p(int num) {*m_p = num;}
};
假設我們現在還沒有寫上面的重寫的拷貝構造函數,也就是說還是執行編譯器為我們默認提供的淺拷貝的拷貝構造函數,執行以下測試:
int main(){int a = 3;int b = 5;Bar obj1(a, &b);Bar obj2 = obj1;std::cout << &obj1 << std::endl;std::cout << &obj2 << std::endl;obj1.print_member();obj2.print_member();obj1.change_p(6);obj1.print_member();obj2.print_member();return 0;
}
得到輸出:
0x7ffe92762a40
0x7ffe92762a50
3,5
3,5
3,6
3,6
兩個對象是在內存地址,是獨立的,這仍然沒有問題。但是問題來了,當我們改變 obj1
的 m_p
指針所指向的值時。obj2 的值也跟著改變了。這時因為默認的拷貝構造函數(淺拷貝)只會將類內的所有成員都復制一份給到新的對象,至于是指針還是值,他一概不管的。這就導致了指針類型的成員變量 m_p 也被原封不動的給到了新的對象 obj2
,這樣兩個對象 obj1
和 obj2
的 m_p
指針的指向的是相同的。從而導致了上面 obj2 的值跟著 obj1變化的情況。這種情況,相當于是值語義的對象中有引用語義的成員。
這種情況下,默認的淺拷貝顯然就不能滿足我們的需求了,這時我們就要自己重寫實現一個拷貝構造函數來完成深拷貝,將指針類型所指向的值重新找一塊地址來存放,從而避免與原對象指向了相同的地址。
實現也一并在上面的代碼塊中了。當我們打開拷貝構造函數的注釋,再執行測試,得到結果如下:
0x7fff232ea1e0
0x7fff232ea1f0
3,5
3,5
3,6
3,5
可以看到,現在兩個對象的改變是完全獨立的了,obj1
的變化并不會影響的 obj2
。程序的行為符合我們的預期。
總結與思考
總結一下,在 C++ 中:
-
由于編譯器提供了默認的拷貝構造函數和賦值運算符,所以自定義的類一般都是值語義的。
-
如果類內沒有指針類型的成員變量,完全可以使用編譯器默認提供的淺拷貝的拷貝構造函數。然而,當類內存在指針類型的成員變量,我們必須重寫拷貝構造函數實現深拷貝,從而避免bug的出現。
那么為什么編譯器不能智能地在合適的時候執行深拷貝呢?在知乎的一個問題中,有人指出了一些原因:
編譯器等…默認的行為都是淺拷貝的原因之一,是深拷貝不一定能夠實現。例如指向的對象可能是多態的(C++沒有標準的虛構造函數),也可能是數組,也可能有循環引用(如 struct N(N *p;};)。所以只能留待成員變量的類來決定怎樣復制。
值得一提的是,除了復制操作,還可以考慮移動和交換操作。它們的性能通常比復制操作更優。自C++11開始也提供了標準的移動操作的實現方法。
Python中的深淺拷貝
在 Python 和 Java 中,變量保存的是對象(值)的引用,也就是說 Python 都是上面提到的 引用語義。我們剛才在C++中提到過,當值語義的對象中有引用語義的成員時,我們需要自己實現深拷貝來保證兩個對象所引用內容的獨立、分離。那在Python中,全都是引用語義,該怎么處理呢,深淺拷貝的區別更需要仔細辨別,有這三種情況:賦值、淺拷貝和深拷貝。
以下是簡要的圖文介紹。詳細可參考:Python中的深拷貝與淺拷貝。
賦值
b = a
賦值引用,a 和 b都指向同一個對象,a 與 b 的變化完全同步。
淺拷貝
b = a.copy()
,也可以 b = copy.copy(a)
,其中后者可以處理所有類型,前者不能處理內置數據類型如 int 。
淺拷貝, a 和 b 是一個獨立的對象,但他們的子對象還是指向統一對象(是引用),所以它們的子對象變化同步,其他不同步。
實際上,淺拷貝指的是重新分配一塊內存,創建一個新的對象,但里面的元素是原對象中各個子對象的引用。
深拷貝
b = copy.deepcopy(a)
深拷貝, a 和 b 完全拷貝了父對象及其子對象,兩者是完全獨立的,兩者的變化也完全無關。
實際上,淺拷貝是指重新分配一塊內存,創建一個新的對象,并且將原對象中的元素,以遞歸的方式,通過創建新的子對象拷貝到新對象中。
Ref:
https://www.zhihu.com/question/36370072
https://www.cnblogs.com/Solstice/archive/2011/08/16/2141515.html
https://www.cnblogs.com/ronny/p/3734110.html
https://blog.csdn.net/weixin_44966641/article/details/122118289?spm=1001.2014.3001.5501