當我們在使用Qt時不可避免得需要接觸到記憶體的分配和使用,即使是在使用Python,Golang這種帶有自動垃圾回收器(GC)的語言時我們仍然需要對Qt的記憶體管理機制有所瞭解,以更加清楚的認識Qt對象的生命周期併在適當的時機加以控制或者避免進入陷阱。 這篇文章里我們將學習QObject & paren ...
當我們在使用Qt時不可避免得需要接觸到記憶體的分配和使用,即使是在使用Python,Golang這種帶有自動垃圾回收器(GC)的語言時我們仍然需要對Qt的記憶體管理機制有所瞭解,以更加清楚的認識Qt對象的生命周期併在適當的時機加以控制或者避免進入陷阱。
這篇文章里我們將學習QObject & parent對象管理機制,以及QWidget與記憶體管理這兩點Qt的基礎知識。
QObject和記憶體管理
在Qt中,我們可以大致把對象分為兩類,一類是QObject
和它的派生類;另一類則是普通的C++類。
對於第二種對象,它的生命周期與管理和普通的C++類基本沒有區別,而QObject
和它的派生類則有以下的顯著區別:
QObject
和其派生類可以使用SIGNAL/SLOT機制- 它們一般會有一個
parent
父對象的指針,用於記憶體管理(後面重點說明) - 對於
QWidget
和其派生類來說,記憶體管理要稍微複雜一些,因為QWidget
需要和eventloop高度配合才能工作(後面也會重點說明)
signal和slot一般來說並不會對記憶體管理產生影響,但是對close()
槽的處理會對QWidget
產生一些影響,所以我們放在後面講解。
那麼先來看一下QObject和parent機制。
QObject的parent
我們時常能看到QWidget或者其他的控制項的構造函數中有一項參數parent
,預設值都為NULL,例如:
QLineEdit(const QString &contents, QWidget *parent = nullptr);
QWidget(QWidget *parent = nullptr, Qt::WindowFlags f = ...);
這個parent
的作用就在於使當前的對象實例加入parent指定的QObject及其派生類的children中,當一個QObject被delete或者調用了它的析構函數時,所有加入的children也會全部被析構。
如果parent
設置為NULL,會有如下的情況:
- 如果是構造時直接指定了NULL,那麼當前實例不會有父對象存在,Qt也不能自動析構該實例除非實例超出作用域導致析構函數被調用,或者用戶在恰當的實際使用
delete
操作符或者使用deleteLater
方法; - 如果已經指定了非NULL的
parent
,這時將它設置成了NULL,那麼當前實例會從父對象的children中刪除,不再受到QObject & parent機制的影響; - 對於
QWidget
,parent
為NULL時代表其為一個頂層視窗,也可以就是獨立於其他widget在系統任務欄單獨出現的widget,對於永遠都是頂層視窗的widget,例如QDialog
,當parent
不為NULL時他會顯示在父widget中心區域的上層; - 如果
QWidget
的parent
為NULL或是其他值,在其加入佈局管理器或者QMainWindow
設置widget時,會自動將parent
設置為相應的父widget,在父控制項銷毀時這些子控制項以及佈局管理器對象會一併銷毀。
所以我們可以看出,QObject對象實際上擁有一顆類實例關係樹,在樹中保存了所有通過指定parent
註冊的子對象,而子對象里又保存有其子對象的關係樹,所以當一個父對象被銷毀時,所有依賴或間接依賴於它的對象都會被正確的釋放,使用者無需手動管理這些資源的釋放操作。
基於此原理,我們可以放心的讓Qt管理資源,這裡有幾個建議:
- 對於QObject及其派生類,如果彼此之間存在一定聯繫,則應該儘量指定parent,對於
QWidget
應該指定parent或者加入佈局管理器由管理器自動設置parent。 - 對象只需要在局部作用域存在時可以選擇不進行記憶體分配,利用局部作用域變數的生命周期自動清理資源。
- 對於非
QWidget
的對象來說,如果不指定非NULLparent
,則需要自己管理對象資源。QWidget
比較特殊,我們在下一節講解。 - 對於在局部作用域上創建的父對象及其子對象,要註意對象銷毀的順序,因為父對象銷毀時也會銷毀子對象,當子對象會在父對象之後被銷毀時會引發double free。
QWidget和記憶體的釋放
QWidget
也是QObject
的子類,所以在parent機制上是沒有區別的,然而實際使用時我們更多的是使用“關閉”(close)而不是delete去刪除控制項,所以差異就出現了。
先提一下widget關閉的流程,首先用戶觸發close()
槽,然後Qt向widget發送QCloseEvent
,預設的QCloseEvent
會做如下處理:
- 將widget隱藏,也就是
hide()
; - 如果有設置
Qt::WA_DeleteOnClose
,那麼會接著調用widget的析構函數
我們可以看到,widget的關閉實際是將其隱藏,而沒有釋放記憶體,雖然我們有時會重寫closeEvent
但也不會手動釋放widget。
看一個因為close機制導致的記憶體泄漏的例子,我們在button被單擊後彈出某個自定義對話框:
button.ConnectClicked(func (_ bool) {
dialog := NewMyDialog()
dialog.Exec()
})
因為dialog在close時會被隱藏,而且沒有設置DeleteOnClose
,所以Qt不會去釋放dialog,而用戶也無法回收dialog的資源,也行你會說golang的GC不是能處理這種情況嗎,然而遺憾的是GC並不能處理cgo分配的資源,所以如果你期望GC做善後的話恐怕要失望了,每次點擊按鈕後記憶體用量都會增加一點,沒錯,記憶體泄露了。
那麼給dialog設置一個parent,像這樣,會如何呢?
dialog.SetParent(self)
遺憾的是,並沒有什麼區別,因為這樣只是把dialog加入父控制項的children,並沒有刪除dialog,只有父對象被銷毀時記憶體才會真正釋放。
解決辦法也有三個。
第一種是使用deleteLater
,例如:
dialog.DeleteLater()
這會通知Qt的eventloop在下次進入主迴圈的時候析構dialog,這樣一來確實解決了記憶體泄露,不過缺點是會有不可預測的延遲存在,有時候延遲是難以接受的。
第二種是手動刪除widget,適用於parent為NULL的場合:
C++:
delete dialog;
golang:
dialog.DestroyMyDialog()
說明一下,DestroyType
也是qtmoc生產的幫助函數,因為golang沒有析構函數的概念,所以goqt使用生成的該幫助函數顯示調用底層C++對象的析構函數。
第三種比較簡單,對於單純顯示而不需要和父控制項做交互的widget,直接設置DeleteOnClose
即可,close時widget會被自動析構。
當然對於PyQt5來說並不會存在如上的問題,sip庫能很好的與python的GC一起工作。唯一需要註意的是有時底層C++對象已經被釋放,但是上層python對象依然存在,這時使用該對象將導致拋錯。
總結
Qt提供了一套方便的機制幫助我們進行記憶體和資源管理,使我們從繁重的勞動中得到了部分的解放,但同時也要註意到那些很容易坑,這樣才能寫出健壯的正確執行的程式。
如有錯誤之處,歡迎批評指正。
參考:
http://doc.qt.io/qt-5/qwidget.html
http://doc.qt.io/qt-5/qobject.html
http://doc.qt.io/qt-5/objecttrees.html
https://stackoverflow.com/questions/20164015/is-deletelater-necessary-in-pyqt-pyside