Пользователь
0,0
рейтинг
27 января 2014 в 22:35

Разработка → PyQt. Управляем памятью, собираем мусор

Qt*, Python*
image
Давным давно был язык С. И были в нем 2 функции управляющие памятью — malloc и free. Но это было слишком сложно.
Посмотрел на это Бьёрн Страуструп и решил что нужно сделать все проще. И изобрел С++. В дополнение к malloc/free там появились new/delete, деструкторы, RAII, auto и shared указатели.
Посмотрел на это Гвидо ван Россум, и решил, что С++ тоже не достаточно прост. Он решил идти другим путем и придумал Python, в котором даже malloc и free нет.
А тем временем норвежские троли создали на С++ GUI-библиотеку Qt, которая упрощает управление памятью для своих объектов за счет того, что сама их удаляет, когда посчитает нужным.
Phil Thompson расстроился, что отличной библиотеки Qt нету для замечательного языка Python. И решил их объединить проектом PyQt. Однако, как оказалось, если скрестить разные парадигмы управления памятью, обязательно вылезут побочные эффекты. Давайте посмотрим какие…

*Историческая справедливость и хронология принесена в жертву художественной составляющей вступления.

Модель работы PyQt можно упрощенно представить следующим образом: для каждого публичного класса С++ создается класс-обертка в Python. Программист работает с объектом-оберткой, а она вызывает методы «настоящего» C++-объекта.
Все хорошо, пока объект и обертка синхронно создаются и синхронно умирают. Но эту синхронность можно нарушить. У меня получалось это сделать 3-я способами:
  • Python-обертка создана, С++объект- нет
  • Сборщик мусора Python удалил нужный объект
  • Qt удалила объект. Python-обертка жива


Python-обертка создана, С++объект- нет



    from PyQt4.QtCore import QObject

    class MyObject(QObject):
        def __init__(self):
            self.field = 7

    obj = MyObject()
    print(obj.field)
    obj.setObjectName("New object")

>>> Traceback (most recent call last):
>>>   File "pyinit.py", line 9, in <module>
>>>     obj.setObjectName("New object")
>>> RuntimeError: '__init__' method of object's base class (MyObject) not called.


Этот и другие примеры можно посмотреть здесь

В конструкторе MyObject мы не вызвали конструктор базового класса. При этом объект успешно создался, им можно пользоваться. Однако при первой попытке вызвать C++-метод мы получим RuntimeError с объяснением, что мы сделали не правильно.
Исправленный вариант:

    ...
    class MyObject(QObject):
        def __init__(self):
            QObject.__init__(self)
    ...


Сборщик мусора Python удалил нужный объект


    from PyQt4.QtGui import QApplication, QLabel

    def createLabel():
        label = QLabel("Hello, world!")
        label.show()

    app = QApplication([])
    createLabel()

    app.exec_()


Если бы этот код был написан на C++, после app.exec_() мы бы получили окошко c «Hello, world!». Но, этот код ничего не покажет. Когда функция createLabel() закончила выполняться, в Python-коде больше не осталось ссылок на label, и заботливый сборщик мусора удалил Python-обертку. В свою очередь обертка удалила C++-объект.

Исправленный вариант:
    from PyQt4.QtGui import QApplication, QLabel

    def createLabel():
        label = QLabel("Hello, world!")
        label.show()
        return label

    app = QApplication([])
    label = createLabel()

    app.exec_()

Сохраняем ссылки на все созданные объекты, даже если не собираемся пользоваться этими ссылками.

Qt удалила объект. Python-обертка жива


Предыдущие 2 случая описаны в документации к PyQt/Pyside и довольно тривиальны. Гораздо более сложные проблемы возникают, когда Python-часть не знает о том, что библиотека Qt удалила C++-объект.
Qt может удалить объект при удалении родительского объекта, закрытии окна, вызове deleteLater() и в некоторых других ситуациях.
После удаления можно работать с методами обертки, написанными на чистом Python, а попытка доступа к C++-части вызывает RuntimeError или падение приложения.

Начнем с очень простого способа выстрелить себе в ногу:
    from PyQt4.QtCore import QTimer
    from PyQt4.QtGui import QApplication, QWidget


    app = QApplication([])

    widget = QWidget()
    widget.setWindowTitle("Dead widget")
    widget.deleteLater()

    QTimer.singleShot(0, app.quit)  # Делаем так, чтобы приложение завершилось сразу после старта
    app.exec_()  #  Запускаем приложение, чтобы оно выполнило deleteLater()

    print(widget.windowTitle())

>>> Traceback (most recent call last):
>>>   File "1_basic.py", line 20, in <module>
>>>     print(widget.windowTitle())
>>> RuntimeError: wrapped C/C++ object of type QWidget has been deleted


Создаем QWidget, просим Qt его удалить. Во время app.exec_() объект будет удален. Обертка об этом не знает, и при попытке вызвать windowTitle() бросит исключение или вызовет падение.
Разумеется, если программист вызвал deleteLater() а потом использует объект, то он сам и виноват. Однако в реальном коде часто случается более сложный сценарий:
  1. Создаем объект
  2. Подключаем внешние сигналы к слотам объекта
  3. Qt удаляет объект. Например, при закрытии окна
  4. Слот удаленного объекта вызывается таймером или сигналом из внешнего мира
  5. Приложение падает или генерирует исключение


Длинный приближенный к жизни пример
    from PyQt4.QtCore import Qt, QTimer
    from PyQt4.QtGui import QApplication, QLabel, QLineEdit


    def onLineEditTextChanged():
        print('~~~~ Line edit text changed')

    def onLabelDestroyed():
        print('~~~~ C++ label object destroyed')

    def changeLineEditText():
        print('~~~~ Changing line edit text')
        lineEdit.setText("New text")


    class Label(QLabel):
        def __init__(self):
            QLabel.__init__(self)
            self.setAttribute(Qt.WA_DeleteOnClose)
            self.destroyed.connect(onLabelDestroyed)

        def __del__(self):
            print('~~~~ Python label objВ качестве источника внешних сигналов используется QLineEdit, а в качестве удаляемого объекта - Label.ect destroyed')

        def setText(self, text):
            print('~~~~ Changing label text')
            QLabel.setText(self, text)

        def close(self):
            print('~~~~ Closing label')
            QLabel.close(self)


    app = QApplication([])
    app.setQuitOnLastWindowClosed(False)

    label = Label()
    label.show()

    lineEdit = QLineEdit()
    lineEdit.textChanged.connect(onLineEditTextChanged)
    lineEdit.textChanged.connect(label.setText)


    QTimer.singleShot(1000, label.close)   # пользователь закрыл одно из окон
    QTimer.singleShot(2000, changeLineEditText)  # пользователь изменил текст в другом окне. Произошло исключение.
    QTimer.singleShot(3000, app.quit)

    app.exec_()

    print('~~~~ Application exited')

>>> ~~~~ Closing label
>>> ~~~~ C++ label object destroyed
>>> ~~~~ Changing line edit text
>>> ~~~~ Line edit text changed
>>> ~~~~ Changing label text
>>> Traceback (most recent call last):
>>>   File "2_reallife.py", line 33, in setText
>>>     QLabel.setText(self, text)
>>> RuntimeError: wrapped C/C++ object of type Label has been deleted
>>> ~~~~ Application exited
>>> ~~~~ Python label object destroyed

Label подключен к сигналу textChanged от QLineEdit. Через 1 секунду после запуска label закрывается и удаляется. Программисту и пользователю он больше не нужен. Однако через 2 секунды удаленный label получает сигнал. В консоль сыплется исключение или приложение неожиданно падает.


Когда слоты не отключаются автоматически


В С++-приложениях при удалении объекта отключаются все его слоты, поэтому проблем не возникает. Однако PyQt и PySide не всегда могут «отключить» объект. Мне стало интересно разобраться, когда слоты не отключаются. В процессе экспериментов родился следующий тест:

Еще больше кода
    PYSIDE = False
    USE_SINGLESHOT = True

    if PYSIDE:
        from PySide.QtCore import Qt, QTimer
        from PySide.QtGui import QApplication, QLineEdit
    else:
        from PyQt4.QtCore import Qt, QTimer
        from PyQt4.QtGui import QApplication, QLineEdit


    def onLineEditDestroyed():
        print('~~~~ C++ lineEdit object destroyed')

    def onSelectionChanged():
        print('~~~~ Pure C++ method selectAll() called')


    class LineEdit(QLineEdit):
        def __init__(self):
            QLineEdit.__init__(self)
            self.setText("foo bar")

            self.destroyed.connect(onLineEditDestroyed)
            #self.selectionChanged.connect(onSelectionChanged)

        def __del__(self):
            print('~~~~ Python lineEdit object destroyed')

        def clear(self):
            """Overridden Qt method
            """
            print('~~~~ Overridden method clear() called')
            QLineEdit.clear(self)

        def purePythonMethod(self):
            """Pure python method.
            Does not override any C++ methods
            """
            print('~~~~ Pure Python method called')
            self.windowTitle()  # generate exception


    app = QApplication([])
    app.setQuitOnLastWindowClosed(False)

    lineEdit = LineEdit()
    lineEdit.deleteLater()


    if USE_SINGLESHOT:
        #QTimer.singleShot(1000, lineEdit.clear)
        #QTimer.singleShot(1000, lineEdit.purePythonMethod)
        QTimer.singleShot(1000, lineEdit.selectAll)  # pure C++ method
    else:
        timer = QTimer(None)
        timer.setSingleShot(True)
        timer.setInterval(1000)
        timer.start()

        #timer.timeout.connect(lineEdit.clear)
        #timer.timeout.connect(lineEdit.purePythonMethod)
        timer.timeout.connect(lineEdit.selectAll)  # pure C++ method


    QTimer.singleShot(2000, app.quit)

    app.exec_()

    print('~~~~ Application exited')



Как выяснилось, результат зависит от того, какие слоты удаленного объекта были подключены к сигналам. Поведение слегка отличается в PyQt и PySide.
Тип слота PyQt PySide
метод С++-объекта слот отключается слот отключается
метод или функция на чистом Python падение слот отключается
метод С++-объекта перегруженный Python-оберткой падение падение

Решение


С удалением C++-объектов особенно тяжело бороться. Проявляется она иногда не скоро, и совсем не явно. Некоторые советы:
  • Если собираетесь удалить объект, у которого есть Python-слоты, вручную отключайте объект от сигналов извне
  • Чтобы отследить момент удаления объекта можно использовать сигнал QObject.destroyed, но не метод __del__ Python-обертки
  • Не используйте QTimer.singleShot для объектов, которые могут быть удалены. Такой таймер невозможно остановить.

Если есть серебрянная пуля, буду рад прочитать про нее в комментариях.

Заключение


Надеюсь никто не сделал вывод, что следует бояться PyQt/PiSide? На практике проблемы случаются не часто. У любого инструмента есть сильные и слабые стороны, которые нужно знать.
Копать Андрей @hlamer
карма
26,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (4)

  • +1
    Полезная статья. Странно, что об этом до сих пор никто не писал.

    Когда только начинал работать с PyQt/PySide, а приложения становились всё больше, то постоянно ловил крэши при выходе из приложения из-за описанных в данной статье проблем с корректным удалением объектов и последовательности их удаления. Особенно замучило то, что сигналы, подключенные к lambda-функциям, почему-то корректно не отключались в PySide. В итоге решил для себя вообще отказаться от использования lambda-функций в качестве слотов.
    • 0
      Буквально на днях сталкивался с подобным в PyQt.

      Была локальная функция, которая вешалась на сигнал из треда для обновления списка в диалоговом окне. Первый же запуск вызвал «изумление» — данные скачиваются, а после закрытия диалога программа молча и без диагностики падает. Всё честно — поюзали диалог и он удалился, а сигналы не отключились ))
  • +1
    1) Многие из таких болячек предотвращаются на ура, если об этом просто помнить всегда.
    Кстати, спасибо за статью — наверняка спасет многих от долгих поисков «решения».

    2) Взято из PyQt / QObject:
    When an object is deleted, it emits a destroyed() signal. You can catch this signal to avoid dangling references to QObjects.
    Если все же словил подобное и развести не удается, то что-нибудь вроде этого иногда помогает (иногда в процессе поиска, иногда как решение — например сделать свой isAlive):
    obj.destroyed[QObject].connect(self.destroyed_slot)
    

    3) Некоторые из таких ошибок можно предотвратить повесив на closeEvent окна или aboutToQuit приложения небольшой цикл ожидания (например ждать максимум 1000ms пока очередь, c qt reference в питоне будет пуста и т.д.).
    • +1
      Кстати о бабочках, может кто-нибудь знает какую-нибудь стандартную ку-питонью обертку или може какой «велосипед» над QPointer::isNull. Имо, иногда очень бы помогало при решении подобных проблем.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.