Ampliando objeto C++ desde Python y mixins

PyQt4 es un "binding" de Qt4 para Python.

Voy a utilizar esto como ejemplo por razones poco interesantes.
  • Utilizo algo de Qt en el trabajo y me viene mejor jugar con Qt que con wxWidgets (por ejemplo)
  • Qt está hecho en C++ y pensado para C++ (con esteroides). Sirve como un contraste interesante de cómo hacer algunas cosas en C++ vs Python (u otros sistemas con mixins)
  • Un interfaz visual es algo menos abstracto y aburrido que un caso abstracto de estudio
  • Planeo hacer una aplicación con interfaz visual (sí PyQt4) para uso personal
Vamos a ampliar QPlainTextEdit para que resalte la línea actual y además, haga un par de cosas cuando se pulse una tecla.

Las mismas ideas se pueden utilizar para añadir resalte de código, autocompletar, y cualquier cosa que necesitemos.

Una primera aproximación...


from PyQt4.QtCore import *
from PyQt4.QtGui import *


class MyPlainTextEdit(QPlainTextEdit):

    def __init__(self, *args):
        QPlainTextEdit.__init__(self, *args)

        self.setFrameStyle(QFrame.NoFrame)
        self.highlight()
        self.setLineWrapMode(QPlainTextEdit.NoWrap)

        self.cursorPositionChanged.connect(self.highlight)

    def highlight(self):
        extraSelections = []

        if (self.isReadOnly() is False):
            lineColor = QColor(Qt.red).lighter(185)
            selection = QTextEdit.ExtraSelection()
            selection.format.setBackground(lineColor)
            selection.format.setProperty(QTextFormat.FullWidthSelection, True)

            cursor = self.textCursor()
            last_selection_line = cursor.blockNumber()
            selection.cursor = cursor
            selection.cursor.movePosition(QTextCursor.EndOfBlock)
            extraSelections.append(QTextEdit.ExtraSelection(selection))

            selection.cursor.movePosition(QTextCursor.StartOfLine)
            selection.cursor.movePosition(QTextCursor.PreviousCharacter)
            while(last_selection_line == selection.cursor.blockNumber()):
                if(selection.cursor.atStart()):
                    break
                extraSelections.append(QTextEdit.ExtraSelection(selection))
                selection.cursor.movePosition(QTextCursor.StartOfLine)
                selection.cursor.movePosition(QTextCursor.PreviousCharacter)

        self.setExtraSelections(extraSelections)


if(__name__ == '__main__'):
    app = QApplication([])
    widget = MyPlainTextEdit()
    widget.show()
    app.exec_()


'__main__' es interesante. Nos permite probar esta pieza de forma totalmente aislada.
Esto existe en muchos otros sistemas, pero en C++ no es tan sencillo y directo (ni muy utilizado)

Heredamos de QPlainTextEdit y ampliamos utilizando la herencia. Que chulada esto de la OOP.
O no tanto.

Si ahora queremos añadir más cosas... lo podemos hacer también en MyPlainTextEdit. Y por si no queremos todas las cosas que se nos ocurra, las hacemos opcionales. Están ahí, pero... no siempre se ven.

Eso es una chapuza. Te puedes encontrar con una enorme clase que hace muchas cosas, de las que utilizas un 10% (o menos). Y lo peor, crecerá y crecerá hasta que no te atrevas a tocarla porque nunca seas capaz de econtrarte en esa enorme cantidad de símbolos y letras.

Para evitar gigantes monolíticos (gran chapuza), tenemos la opción de utilizar la herencia varias veces.

Tralarí -> CheckSpelling -> Tralará -> PythonSintax -> echo -> WordCompletion -> highlight -> QPlainTextEdit


Dependiende de la clase que quieras, podrías engancharte a resalte, eco, tralarí... tenemos piezas más pequeñas para mantener y te limitas a coger lo que necesitas.

O no.

¿No sería mejor...?

Tralará -> PythonSintax ->  WordCompletion -> highlight -> CheckSpelling -> Tralarí -> echo ->  QPlainTextEdit


Depende del contexto. En el momento del diseño no lo sabemos (no siempre y nunca si no es un desarrollo muy específico)

Así se trabaja en muchos sistemas OOP.  :-(

Queremos evitar la herencia múltiple por sus riesgos. En ocasiones, simplemente está prohibido.


Podríamos utilizar signal/slot para hacer la ampliación con mayor desacoplamiento.

Qt no trabaja así. Por algo será.
Por un lado, el signal/slot hasta Qt4 no tiene un coste cpu considerable.
Tampoco sería un sistema limpio para ampliaciones de comportamiento.

Pero no estamos en C++, ni Java, ni C#... ¿Qué opciones tenemos en Python?

Deja el martillo y coge otra herramienta (aquí)


Python es un lenguaje dinámico. No sólo la definición de los tipos se establece en tiempo de ejecución, los tipos se pueden definir cambiar y ampliar en tiempo de ejecución (o simplemente más tarde).



from PyQt4.QtCore import *
from PyQt4.QtGui import *


def highlight(self):
    def highlight__internal():
        extraSelections = []

        if (self.isReadOnly() is False):
            lineColor = QColor(Qt.red).lighter(185)
            selection = QTextEdit.ExtraSelection()
            selection.format.setBackground(lineColor)
            selection.format.setProperty(QTextFormat.FullWidthSelection, True)

            cursor = self.textCursor()
            last_selection_line = cursor.blockNumber()
            selection.cursor = cursor
            selection.cursor.movePosition(QTextCursor.EndOfBlock)
            extraSelections.append(QTextEdit.ExtraSelection(selection))

            selection.cursor.movePosition(QTextCursor.StartOfLine)
            selection.cursor.movePosition(QTextCursor.PreviousCharacter)
            while(last_selection_line == selection.cursor.blockNumber()):
                if(selection.cursor.atStart()):
                    break
                extraSelections.append(QTextEdit.ExtraSelection(selection))
                selection.cursor.movePosition(QTextCursor.StartOfLine)
                selection.cursor.movePosition(QTextCursor.PreviousCharacter)

        self.setExtraSelections(extraSelections)
    #self.cursorPositionChanged.connect(self.highlight)
    #self.highlight()
    ## it's a pitty, self.highlight it's been created and it doesn't exist at this point
    return highlight__internal


if(__name__ == '__main__'):
    app = QApplication([])
    widget = QPlainTextEdit()

    #adding highlight feature
    widget.highlight = highlight(widget)
    widget.cursorPositionChanged.connect(widget.highlight)
    widget.highlight()
    #adding highlight feature

    widget.show()
    app.exec_()






Empecemos por el '__main__'.
Creamos una instancia de la sencilla y sosa clase QPlainTextEdit y luego le añadimos el comportamiento.
Podríamos no haberlo añadido, añadir otro comportamiento y añadir más cosas.

Esto tiene buena pinta.

Pero esto debería ser más sencillo.
    #adding highlight feature
    widget.highlight = highlight(widget)
    widget.cursorPositionChanged.connect(widget.highlight)
    widget.highlight()
    #adding highlight feature

Desgraciadamente no podemos hacerlo donde corresponde...
    #self.cursorPositionChanged.connect(self.highlight)
    #self.highlight()
    ## it's a pitty, self.highlight it's been created and it doesn't exist at this point

Observa como hemos tenido que utilizar un closure para realizar la ampliación. Esto es pensar de otra forma a C++, Java, C# y otros lenguajes que no tienen closures.

Los closures son útiles para muchas cosas. Este es sólo un caso concreto.


Pero sí lo podemos limpiar más. Dejando la ampliación en una sola línea. Mucho mejor.


from PyQt4.QtCore import *
from PyQt4.QtGui import *


def highlight(self):
    def highlight__internal():
        extraSelections = []

        if (self.isReadOnly() is False):
            lineColor = QColor(Qt.red).lighter(185)
            selection = QTextEdit.ExtraSelection()
            selection.format.setBackground(lineColor)
            selection.format.setProperty(QTextFormat.FullWidthSelection, True)

            cursor = self.textCursor()
            last_selection_line = cursor.blockNumber()
            selection.cursor = cursor
            selection.cursor.movePosition(QTextCursor.EndOfBlock)
            extraSelections.append(QTextEdit.ExtraSelection(selection))

            selection.cursor.movePosition(QTextCursor.StartOfLine)
            selection.cursor.movePosition(QTextCursor.PreviousCharacter)
            while(last_selection_line == selection.cursor.blockNumber()):
                if(selection.cursor.atStart()):
                    break
                extraSelections.append(QTextEdit.ExtraSelection(selection))
                selection.cursor.movePosition(QTextCursor.StartOfLine)
                selection.cursor.movePosition(QTextCursor.PreviousCharacter)

        self.setExtraSelections(extraSelections)
    self.cursorPositionChanged.connect(highlight__internal)
    highlight__internal()
    return highlight__internal


if(__name__ == '__main__'):
    app = QApplication([])
    widget = QPlainTextEdit()

    #adding highlight feature
    widget.highlight = highlight(widget)
    ##adding highlight feature

    widget.show()
    app.exec_()



Esto está muy bien, pero si se pudiera simplificar aún más...

Podemos utilizar los mixins (tampoco disponibles en Java, C# y otros). C++ tampoco los tiene aunque se podría utilizar la misma idea con metaprogramación de plantillas. No es muy utilizado por no dejar un código muy claro, elevar los tiempos de compilación y los mensajes crípticos de error (todavía no tenemos "concepts" y lo seguimos pagando)

Quedaría así...

from PyQt4.QtCore import *
from PyQt4.QtGui import *


class WithHighlight(QPlainTextEdit):

    def __init__(self, *args):
        QPlainTextEdit.__init__(self, *args)

        self.highlight()
        self.setLineWrapMode(QPlainTextEdit.NoWrap)

        self.cursorPositionChanged.connect(self.highlight)

    def highlight(self):
        extraSelections = []

        if (self.isReadOnly() is False):
            lineColor = QColor(Qt.red).lighter(185)
            selection = QTextEdit.ExtraSelection()
            selection.format.setBackground(lineColor)
            selection.format.setProperty(QTextFormat.FullWidthSelection, True)

            cursor = self.textCursor()
            last_selection_line = cursor.blockNumber()
            selection.cursor = cursor
            selection.cursor.movePosition(QTextCursor.EndOfBlock)
            extraSelections.append(QTextEdit.ExtraSelection(selection))

            selection.cursor.movePosition(QTextCursor.StartOfLine)
            selection.cursor.movePosition(QTextCursor.PreviousCharacter)
            while(last_selection_line == selection.cursor.blockNumber()):
                if(selection.cursor.atStart()):
                    break
                extraSelections.append(QTextEdit.ExtraSelection(selection))
                selection.cursor.movePosition(QTextCursor.StartOfLine)
                selection.cursor.movePosition(QTextCursor.PreviousCharacter)

        self.setExtraSelections(extraSelections)


class MyEditComponent(WithHighlight, QPlainTextEdit):
    pass


if(__name__ == '__main__'):
    app = QApplication([])
    widget = MyEditComponent()
    widget.show()
    app.exec_()


No está nada mal.

Ahora le añadimos otro comportamiento, que haga eco de las teclas pulsadas


from PyQt4.QtCore import *
from PyQt4.QtGui import *


class WithHighlight(QPlainTextEdit):

    def __init__(self, *args):
        QPlainTextEdit.__init__(self, *args)

        self.highlight()
        self.setLineWrapMode(QPlainTextEdit.NoWrap)

        self.cursorPositionChanged.connect(self.highlight)

    def highlight(self):
        extraSelections = []

        if (self.isReadOnly() is False):
            lineColor = QColor(Qt.red).lighter(185)
            selection = QTextEdit.ExtraSelection()
            selection.format.setBackground(lineColor)
            selection.format.setProperty(QTextFormat.FullWidthSelection, True)

            cursor = self.textCursor()
            last_selection_line = cursor.blockNumber()
            selection.cursor = cursor
            selection.cursor.movePosition(QTextCursor.EndOfBlock)
            extraSelections.append(QTextEdit.ExtraSelection(selection))

            selection.cursor.movePosition(QTextCursor.StartOfLine)
            selection.cursor.movePosition(QTextCursor.PreviousCharacter)
            while(last_selection_line == selection.cursor.blockNumber()):
                if(selection.cursor.atStart()):
                    break
                extraSelections.append(QTextEdit.ExtraSelection(selection))
                selection.cursor.movePosition(QTextCursor.StartOfLine)
                selection.cursor.movePosition(QTextCursor.PreviousCharacter)

        self.setExtraSelections(extraSelections)

    def keyPressEvent(self, key):
        print(key.text() + "........")
        super(WithHighlight, self).keyPressEvent(key)


class MyEditComponent(WithHighlight, QPlainTextEdit):
    pass


if(__name__ == '__main__'):
    app = QApplication([])
    widget = MyEditComponent()
    widget.show()
    app.exec_()


Ahora sí está mal. WithHighlight debería llamarse  WithHighlightAndEcho

Eso no es lo que queríamos. Pero la solución es fácil con los mixins de Python


import sys
from PyQt4.QtCore import *
from PyQt4.QtGui import *


class WithHighlight(QPlainTextEdit):

    def __init__(self, *args):
        self.highlight()
        self.setLineWrapMode(QPlainTextEdit.NoWrap)

        self.cursorPositionChanged.connect(self.highlight)

    def highlight(self):
        extraSelections = []

        if (self.isReadOnly() is False):
            lineColor = QColor(Qt.red).lighter(185)
            selection = QTextEdit.ExtraSelection()
            selection.format.setBackground(lineColor)
            selection.format.setProperty(QTextFormat.FullWidthSelection, True)

            cursor = self.textCursor()
            last_selection_line = cursor.blockNumber()
            selection.cursor = cursor
            selection.cursor.movePosition(QTextCursor.EndOfBlock)
            extraSelections.append(QTextEdit.ExtraSelection(selection))

            selection.cursor.movePosition(QTextCursor.StartOfLine)
            selection.cursor.movePosition(QTextCursor.PreviousCharacter)
            while(last_selection_line == selection.cursor.blockNumber()):
                if(selection.cursor.atStart()):
                    break
                extraSelections.append(QTextEdit.ExtraSelection(selection))
                selection.cursor.movePosition(QTextCursor.StartOfLine)
                selection.cursor.movePosition(QTextCursor.PreviousCharacter)

        self.setExtraSelections(extraSelections)


class WithEcho(QPlainTextEdit):

    def __init__(self, *args):
        pass

    def keyPressEvent(self, key):
        sys.stdout.write(key.text())
        sys.stdout.flush()
        super(WithEcho, self).keyPressEvent(key)


class MyEditComponent(WithHighlight, WithEcho, QPlainTextEdit):
    def __init__(self, *args):
        QPlainTextEdit.__init__(self, *args)
        WithHighlight.__init__(self, *args)
        WithEcho.__init__(self, *args)


if(__name__ == '__main__'):
    app = QApplication([])
    widget = MyEditComponent()
    widget.show()
    app.exec_()



Y podríamos configurar nuestra nueva clase con todas las combinaciones que queramos...



class MyEditComponent(WithEcho, WithHighlight, QPlainTextEdit):
    pass

class MyEditComponent(WithHighlight, QPlainTextEdit):
    pass

class MyEditComponent(WithEcho, QPlainTextEdit):
    pass

class MyEditComponent(QPlainTextEdit):
    pass




Las siguienes son equivalentes...


class MyEditComponent(WithEcho, WithHighlight, QPlainTextEdit):
    pass

class MyEditComponent(WithHighlight, WithEcho, QPlainTextEdit):
    pass


Con dos opciones de ampliación, tenemos 4 posibles combinaciones. Si añadimos más comportamientos, las combinaciones se disparan y estaremos muy contentos de tener mixins.


Pero esto...


class MyEditComponent(WithHighlight, WithEcho, QPlainTextEdit):
    def __init__(self, *args):
        QPlainTextEdit.__init__(self, *args)
        WithHighlight.__init__(self, *args)
        WithEcho.__init__(self, *args)

Es feo. Debería ser más sencillo. Cada uno debería limpiar su casa. Más sencillo de mantener.

Más aún me gusta la solución de Scala. Crear las clases al vuelo con los comportamientos que necesites en cada momento.



if(__name__ == '__main__'):
    app = QApplication([])
    widget = mixin(QPlainTextEdit, WithEcho)()
    widget.show()
    app.exec_()


Y para dos comportamientos...


if(__name__ == '__main__'):
    app = QApplication([])
    widget = mixin(mixin(QPlainTextEdit, WithHighlight), WithEcho)()
    
    widget.show()
    app.exec_()


Y mucho mejor, más claro...


if(__name__ == '__main__'):
    app = QApplication([])
    widget = mixin(QPlainTextEdit, WithEcho, WithHighlight)()
    widget.show()
    app.exec_()

Sí, he invertido el orden de las clases respecto a los mixins de Python. Me gusta más así.


Y para conseguirlo sólo es necesario esto:


def mixin(base, *mixs):
    def mixin__internal(base, addition):
        class NewClass(addition, base):
            def __init__(self, *args):
                base.__init__(self, *args)
                addition.__init__(self, *args)
        return NewClass

    newClass = base
    for mix in mixs:
        newClass = mixin__internal(newClass, mix)
    return newClass


A lo que convendría añadirle una pequeña prueba de desarrollo


# TEST
if(__name__ == '__main__'):
    class WithAdd:
        def __init__(self, *args):
            pass

        def add(self, value):
            return self.number + value

    class WithSubs:
        def __init__(self, *args):
            pass

        def subtract(self, value):
            return self.number - value

    class myClass:
        def __init__(self, number):
            self.number = number

    def test_2():
        MixedClass_ = mixin(myClass, WithAdd)
        MixedClass = mixin(MixedClass_, WithSubs)
        myInstance = MixedClass(4)
        print myInstance.add(2)
        print myInstance.subtract(2)

    def test_n():
        MixedClass = mixin(myClass, WithAdd, WithSubs)
        myInstance = MixedClass(40)
        print myInstance.add(2)
        print myInstance.subtract(2)

    test_2()
    test_n()

Y meterlo en un fichero Mixin

Todavía  mejor si lo integramos en pruebas unitarias. Pero por el momento, no es crítico.


El código fuente final sería así.



import sys
from PyQt4.QtCore import *
from PyQt4.QtGui import *
from Mixin import mixin


class WithHighlight(QPlainTextEdit):

    def __init__(self, *args):
        self.highlight()
        self.setLineWrapMode(QPlainTextEdit.NoWrap)

        self.cursorPositionChanged.connect(self.highlight)

    def highlight(self):
        extraSelections = []

        if (self.isReadOnly() is False):
            lineColor = QColor(Qt.red).lighter(185)
            selection = QTextEdit.ExtraSelection()
            selection.format.setBackground(lineColor)
            selection.format.setProperty(QTextFormat.FullWidthSelection, True)

            cursor = self.textCursor()
            last_selection_line = cursor.blockNumber()
            selection.cursor = cursor
            selection.cursor.movePosition(QTextCursor.EndOfBlock)
            extraSelections.append(QTextEdit.ExtraSelection(selection))

            selection.cursor.movePosition(QTextCursor.StartOfLine)
            selection.cursor.movePosition(QTextCursor.PreviousCharacter)
            while(last_selection_line == selection.cursor.blockNumber()):
                if(selection.cursor.atStart()):
                    break
                extraSelections.append(QTextEdit.ExtraSelection(selection))
                selection.cursor.movePosition(QTextCursor.StartOfLine)
                selection.cursor.movePosition(QTextCursor.PreviousCharacter)

        self.setExtraSelections(extraSelections)


class WithEcho(QPlainTextEdit):

    def __init__(self, *args):
        pass

    def keyPressEvent(self, key):
        sys.stdout.write(key.text())
        sys.stdout.flush()
        super(WithEcho, self).keyPressEvent(key)


if(__name__ == '__main__'):
    app = QApplication([])
    widget = mixin(QPlainTextEdit, WithEcho, WithHighlight)()
    widget.show()
    app.exec_()


Que no está nada mal.

Y podemos demostrar lo sencillo y elegante que queda la ampliación añadiendo otro comportamiento:


import sys
from PyQt4.QtCore import *
from PyQt4.QtGui import *
from Mixin import mixin


class WithHighlight(QPlainTextEdit):

    def __init__(self, *args):
        self.highlight()
        self.setLineWrapMode(QPlainTextEdit.NoWrap)

        self.cursorPositionChanged.connect(self.highlight)

    def highlight(self):
        extraSelections = []

        if (self.isReadOnly() is False):
            lineColor = QColor(Qt.red).lighter(185)
            selection = QTextEdit.ExtraSelection()
            selection.format.setBackground(lineColor)
            selection.format.setProperty(QTextFormat.FullWidthSelection, True)

            cursor = self.textCursor()
            last_selection_line = cursor.blockNumber()
            selection.cursor = cursor
            selection.cursor.movePosition(QTextCursor.EndOfBlock)
            extraSelections.append(QTextEdit.ExtraSelection(selection))

            selection.cursor.movePosition(QTextCursor.StartOfLine)
            selection.cursor.movePosition(QTextCursor.PreviousCharacter)
            while(last_selection_line == selection.cursor.blockNumber()):
                if(selection.cursor.atStart()):
                    break
                extraSelections.append(QTextEdit.ExtraSelection(selection))
                selection.cursor.movePosition(QTextCursor.StartOfLine)
                selection.cursor.movePosition(QTextCursor.PreviousCharacter)

        self.setExtraSelections(extraSelections)


class WithEcho(QPlainTextEdit):

    def __init__(self, *args):
        pass

    def keyPressEvent(self, key):
        sys.stdout.write(key.text())
        sys.stdout.flush()
        super(WithEcho, self).keyPressEvent(key)


class WithTralari(QPlainTextEdit):

    def __init__(self, *args):
        pass

    def keyPressEvent(self, key):
        sys.stdout.write(key.text()+"tralarI")
        sys.stdout.flush()
        super(WithTralari, self).keyPressEvent(key)


if(__name__ == '__main__'):
    app = QApplication([])
    widget = mixin(QPlainTextEdit, WithEcho, WithHighlight, WithTralari)()
    widget.show()
    app.exec_()


No soy experto en Python y no soy (por el momento) un fan del mismo.
Me gusta, y mucho. Tiene algunas características claras muy interesantes y algunas limitaciones aún más interesantes.
Pero no soy un fan porque me gustan mucho C++, Erlang, Elixir, Lisp, Scala, Python, Ruby, Go, Boo, Nemerle, Rust...  (no necesariamente en este orden)




Comentarios

Entradas populares de este blog

Manifiesto ágil, un buen punto de partida

No seas estúpido

El principio