Orientación a Objetos
1 Introducción
Con las funciones, que podemos agrupar en módulos, manipulamos datos. Ahora vamos a combinar los datos y su tratamiento en un paquete denominado clase.
Una clase es la definición de un modelo abstracto que tiene propiedades y comportamientos relacionados entre sí en un objeto.
Cuando definimos una clase, describimos un modelo con los datos y las funciones que actuarán sobre esos datos, estructuramos así, unas propiedades y un comportamiento. Una clase no ocupa ningún espacio en la memoria del ordenador (si obviamos que el código fuente ocupa un espacio). Tendremos que crear un objeto a partir de esa clase para poder almacenar y manipular los datos.
La programación orientada a objetos, no sólo representan los datos, sino también la estructura general de tratamiento de esos datos, agrupados en objetos individuales.
Cuando se realiza un diseño orientado a objeto el primer paso es determinar cuáles son las propiedades que vamos a tener, después estableceremos las operaciones que se podrán realizar sobre ellas.
La programación orientada a objetos posee tres características fundamentales: encapsulación, herencia y polimorfismo.
- La encapsulación es un mecanismo de control. Los datos de un objeto sólo deben ser modificados por medio de un método de ese objeto. Un cliente nunca debe ser capaz de acceder al estado de un objeto directamente. La modificación de un atributo del objeto debe realizarse por medio de un método y la consulta del valor de un atributo debe realizarse por medio de un método especialmente dedicado para ello. De esta forma se implementa el ocultamiento de información y se reduce el impacto de efectos colaterales provenientes de cambios incontrolados sobre los datos.
- La herencia es un mecanismo de abstracción consistente en la capacidad de derivar nuevas clases a partir de otras ya existentes. Las clases que derivan de otras heredan automáticamente todo su comportamiento, pero además pueden introducir características particulares propias que las diferencian. Las clases derivadas o subclases proporcionan comportamientos especializados a partir de los elementos comunes que heredan de la clase base. La herencia proporciona la capacidad de reutilizar código.
- El polimorfismo permite que diferentes objetos puedan responder al mismo mensaje en diferentes formas. Implica la posibilidad de usar una sola referencia para tratar diversos objetos relacionados jerárquicamente.
Después de esta brevísima introducción a la orientación a objetos vamos a ver que ofrece Python, lenguaje que se ha concebido desde su origen sobre el paradigma de la orientación a objetos.
2 Orientación a objetos
Cada objeto tiene un tipo y un valor. El tipo de un objeto es inmutable, determina las operaciones que admite y define los valores posibles para los objetos de ese tipo. El valor de algunos objetos puede cambiar y define el estado del objeto en un momento determinado.
2.1 Clases
Una clase es un modelo abstracto que define un objeto. La clase define las características de los miembros, compuestos por atributos o propiedades (campos o variables de clase), y métodos (las funciones), de los que van a disponer todos los objetos que construyamos a partir de la clase. Así, el tipo de dato de un objeto es la clase que define las características del mismo.
Una clase se declara de la forma:
class <nombre_clase>:
<declaraciones>
El cuerpo de la clase debe llevar la sangría correspondiente tras los dos puntos.
Dentro de una clase tendremos declaraciones de propiedades y métodos que actuarán sobre las propiedades.
PEP 8: Clases
Los nombres de las clases se definen en singular.
Deben empezar con mayúscula y emplear la notación camello.
2.1.1 Propiedades
Los campos, atributos o propiedades son los datos contenidos en una clase. Pueden ser tipos de datos básicos o estructuras más complejas, como objetos de otras clases.
Los campos pueden pertenecer a cada objeto de la clase o pueden pertenecer a la clase misma. Se denominan atributos de instancia y atributos de clase respectivamente.
PEP 8: Propiedades
Los nombres de las propiedades siguen las mismas reglas de estilo que las variables.
2.1.2 Métodos
Los métodos son las operaciones que se realizan sobre los datos contenidos en un objeto, describen el comportamiento de los objetos de una clase.
Los métodos tienen la misma sintaxis que las funciones, excepto que deben llevar un parámetro extra al principio de la lista de parámetros que hace referencia al objeto actual, a la instancia actual de la clase. Por convención se denomina self. No es una palabra reservada, pero como todo el mundo la usa, lo seguiremos haciendo.
def <nombre_método>(self[, <parámetros>]):
<sentencias>
No es necesario que incluyamos self en la llamada a los métodos. Python se encarga de añadir el argumento self a la relación de argumentos cuando usamos un método.
Al referimos a las variables dentro de una clase, estas deben estar precedidas por self. El propósito es distinguir las variables de la clase de otras variables del programa.
PEP 8: Métodos
Los nombres de los métodos siguen las mismas reglas de estilo que las funciones.
Usar siempre self para el primer argumento de los métodos de instancia.
Usar siempre cls para el primer argumento de los métodos de clase.
2.1.2.1 Constructor
El constructor es un método especial de la clase, es el método de inicialización que Python llama cuando se crea una nueva instancia de esa clase.
La función básica del constructor es inicializar los miembros del objeto recién creado.
La ejecución del constructor siempre se realiza después de la inicialización de los campos del objeto con los valores que se hayan especificado.
Tiene como nombre __init__(), y su sintaxis es:
def __init__(self[, <parámetros>]):
<sentencias>
Como ocurre en todas las funciones, los parámetros pueden especificarse con valores por defecto.
Vamos a ver la creación de una clase con su constructor.
>>> class Punto: ... """Clase que define un punto en un espacio bidimensional""" ... ... # constructor ... def __init__(self, x=0, y=0): ... self.x = x ... self.y = y ... ... # método para incrementar el valor del punto ... def incremento(self, a, b): ... self.x += a ... self.y += b
En nuestra clase Punto, el constructor guarda los argumentos que recibe en las variables de instancia self.x y self.y, que por defecto tendrán el valor 0. La clase nos proporciona el método incremento() para incrementar las variables x e y con los valores que reciba.
2.1.3 Objeto
Una clase es un modelo abstracto, mientras que un objeto es la realización de esa clase, una instancia única. Comprende tanto miembros de datos como métodos.
Los objetos se crean como una nueva instancia de la clase, usando la notación de funciones, pasando los argumentos que requiera el método __init__(), y se referencian mediante un identificador.
<variable_objeto> = <nombre_clase>([<parámetros>])
Cada objeto tiene una identidad, un tipo y un valor. La identidad del objeto nunca cambia una vez creado. El tipo de un objeto determina las operaciones que admite.
La inicialización proporciona el valor inicial del objeto.
Al conjunto de datos relacionados con un objeto en un momento dado se le conoce como estado del objeto. Un objeto puede tener múltiples estados a lo largo de su existencia conforme se relaciona con su entorno y otros objetos.
Para acceder a los atributos y métodos del objeto se usa el operador punto ( . ) utilizando el nombre que se haya empleado en la creación del objeto y la propiedad o método que deseemos usar.
<variable_objeto>.<atributo> <variable_objeto>.<método>([<parámetros>])
En el caso de que la clase esté definida en un módulo aparte ha de incluirse previamente con la sentencia import.
Siguiendo con la clase Punto, que hemos definido más arriba, vamos a crear un objeto de tipo Punto y a usar sus atributos y método.
>>> # creación de un objeto de la clase Punto
>>> p1 = Punto(1, 2)
>>> print(f"p1 = ({p1.x}, {p1.y})")
p1 = (1, 2)
>>> # llamada a un método de la clase
>>> p1.incremento(3, 4)
>>> print(f"p1 = ({p1.x}, {p1.y})")
p1 = (4, 6)
2.1.4 Ámbito de los atributos
Ya hemos comentado, al hablar de las propiedades, que podemos tener variables o atributos de instancia o de clase.
- Las variables de clase son compartidas por todas las instancias de esa clase. Sólo hay una copia de la variable de clase y cuando cualquier objeto hace un cambio en una variable de clase, ese cambio será visto por todas las demás instancias.
- Las variables de instancia son propiedad de cada objeto o instancia individual de la clase. En este caso, cada objeto tiene su propia copia del campo, no se comparten y no están relacionados de ninguna manera con el campo del mismo nombre en una instancia diferente.
En Python no hay variables privadas de instancia, que solo se puedan acceder desde dentro del objeto, como ocurre en otros lenguajes. (Ver el punto sobre Encapsulación en la introducción).
Vamos a incluir en nuestro ejemplo de la clase Punto una variable a nivel de la clase.
>>> class Punto: ... punto_inicial = [0, 0] # variable de clase ... ... # constructor ... def __init__(self, x, y): ... self.x = x ... self.y = y ... self.punto_inicial[0] = x ... self.punto_inicial[1] = y
Si creamos dos objetos de la clase Punto y verificamos los valores de las variables de instancia y variables de clase.
>>> # primer punto
>>> p1 = Punto(1, 2)
>>> print(f"p1 = ({p1.x}, {p1.y})")
p1 = (1, 2)
>>> print(f"Punto inicial p1 = ({p1.punto_inicial[0]}, {p1.punto_inicial[1]}")
Punto inicial p1 = (1, 2)
>>> # segundo punto
>>> p2 = Punto(8, 9)
>>> print(f"p2 = ({p2.x}, {p2.y})")
p2 = (8, 9)
>>> # comprobar variable de clase en ambos puntos
>>> print(f"Punto inicial p2 = ({p2.punto_inicial[0]}, {p2.punto_inicial[1]}")
Punto inicial p2 = (8, 9)
>>> print(f"Punto inicial p1 = ({p1.punto_inicial[0]}, {p1.punto_inicial[1]}")
Punto inicial p1 = (8, 9)
La comprobación de la variable de clase nos muestra que el atributo punto_inicial es común para todas las instancias de la clase.
Si queremos que cada instancia de la clase Punto registre el punto con el que fue creado el objeto, la forma correcta de diseñar la clase sería con una variable de instancia.
>>> class Punto: ... # constructor ... def __init__(self, x, y): ... self.x = x ... self.y = y ... self.punto_inicial = [] # variable de instancia ... self.punto_inicial.append(x) ... self.punto_inicial.append(y)
2.1.5 Cadena de documentación
La primera cadena después de la cabecera de la clase se conoce como cadena de documentación (docstring). Se utiliza para explicar brevemente en qué consiste la clase. Es recomendable usar comillas triples para que el texto se pueda extender por varias líneas.
La cadena de documentación es accesible empleando el atributo __doc__ de la clase.
class <nombre_clase>:
"""<cadena de documentación>"""
<atributos>
<métodos>
Aunque es opcional, la documentación es una buena práctica de programación.
2.2 Herencia
La herencia es un mecanismo de reusabilidad y extensibilidad que permite definir nuevas clases a partir de otras ya existentes y ampliar sus capacidades añadiendo nuevas características, atributos y métodos, en la clase derivada si es necesario. Podemos decir que una clase derivada es una clase más especializada.
Hacer cambios en la lógica del programa en lugar de reescribir código, facilita la reutilización y extensión del código.
En Python la sintaxis para definir la herencia de una clase es:
class <nombre_clase>(<clase_base>):
<declaraciones>
En la terminología orientada a objetos, la clase de la que se hereda se denomina superclase o clase base y la clase que hereda subclase o clase derivada. También es frecuente hablar de clases padre e hijo (e incluso madre e hija).
La herencia ofrece automáticamente la reutilización del código ya que la clase derivada dispone de las propiedades y métodos de la clase base.
En el momento de instanciar una clase que derive de otra se procede teniendo en cuenta a la clase base. Esto se usa para resolver referencias a atributos, si un atributo solicitado no se encuentra en la clase derivada, la búsqueda continúa por la clase base.
Vamos a definir dos clases: Padre e Hijo, donde la clase Hijo hereda de la clase Padre.
>>> class Padre:
... def __init__(self, dato):
... self.pdato = dato
... print("Constructor del padre")
...
... def test(self):
... print(f"Método del padre: {self.pdato}")
>>> class Hijo(Padre):
... def __init__(self, dato):
... self.hdato = dato
... print("Constructor del hijo")
... super().__init__(dato * 2)
Creamos sendos objeto de las clases Padre e Hijo y ejecutamos el método test(), heredado de la clase Padre.
>>> p = Padre(987)
Constructor del padre
>>> h = Hijo(123)
Constructor del hijo
Constructor del padre
>>> h.test()
Método del padre: 246
Podemos ver como al llamar desde el método del hijo al método del padre, el valor del atributo pdato ha cambiado, cambio que habíamos realizado desde la inicialización del hijo.
Si vemos las propiedades y métodos de los objetos creados p y h, podemos comprobar que el hijo dispone del atributo pdato y del método test() que no están definidos en la clase Hijo, pero ha heredado de la superclase Padre.
>>> dir(p) ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', . . . . . . '__subclasshook__', '__weakref__', 'pdato', 'test'] >>> dir(h) ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', . . . . . . '__subclasshook__', '__weakref__', 'hdato', 'pdato', 'test']
Las funciones integradas isinstance() e issubclass()se utilizan para comprobar la relación de clases e instancias.
La función isinstance() verifica si un objeto es una instancia de una clase
La función issubclass() verifica si un objeto es una subclase de una clase. Una clase se considera siempre una subclase de sí misma.
Continuando con las clases Padre e Hijo del ejemplo anterior.
>>> h = Hijo(123)
Constructor del hijo
Constructor del padre
>>> p = Padre(987)
Constructor del padre
>>> # test instancias
>>> isinstance(h, Padre)
True
>>> isinstance(p, Hijo)
False
>>> # test subclases
>>> issubclass(Hijo, Padre)
True
>>> issubclass(Padre, Hijo)
False
>>> issubclass(Hijo, Hijo)
True
2.2.1 Sustitución de métodos en la herencia
La sustitución (overriding) de métodos es una parte del mecanismo de herencia que permite a una clase derivada cambiar el código de un método proporcionado por una clase base. De esta forma se puede mejorar o personalizar parte del código de los métodos de la superclase.
En Python la sustitución se produce simplemente definiendo en la clase hija un método con el mismo nombre de un método de la clase padre. Al definir un método de esta forma en la llamada al método desde el objeto del hijo se ejecuta ese nuevo método y no el de sus ancestros.
En este caso el método de la clase base se anula, pero el método está ahí y podemos acceder a él llamándolo explícitamente, haciendo uso de la función incorporada super(). (Lo hemos empleado con anterioridad en el ejemplo del punto Herencia, al llamar al métoso __init__ de la superclase).
super().<método>()
O mediante el nombre de la superclase.
<clase_base>.<método>(self)
Este segundo método es útil en el caso de herencia múltiple, para referirnos a uno determinado.
Vamos a modificar un poco nuestras clase Padre e Hijo.
>>> class Padre:
... def __init__(self, dato):
... self.pdato = dato
... print("Constructor del padre")
...
... def test(self):
... print(f"Método del padre: {self.pdato}")
>>> class Hijo(Padre):
... def __init__(self, dato):
... self.hdato = dato
... print("Constructor del hijo")
... super().__init__(dato * 2)
...
... def test(self):
... print(f"Método del hijo: {self.hdato}")
Si creamos un objeto de la clase Hijo y ejecutamos el método test().
>>> h = Hijo(123)
Constructor del hijo
Constructor del padre
>>> h.test()
Método del hijo: 123
Vemos que se ejecuta el constructor del objeto de la clase Hijo y después el de la del Padre, al estar la llamada a su constructor en la inicialización del hijo. Después ejecutamos el método test() desde el objeto de la clase Hijo, y vemos que se ejecuta el método que ha sustituido al de la clase base Padre.
Vamos ahora a llamar al método test() del Padre desde el método test() del Hijo, modificando el método de la forma:
def test(self):
... print(f"Método del hijo: {self.hdato}")
... Padre.test(self)
Y ahora al seguir los mismos pasos de ejecución.
>>> h = Hijo(123)
Constructor del hijo
Constructor del padre
>>> h.test()
Método del hijo: 123
Método del padre: 246
Al llamar desde el método test() del hijo al método del padre, el valor del atributo pdato ha cambiado, cambio que habíamos realizado desde la inicialización del hijo.
En muchas ocasiones sustituimos métodos de las superclases para mejorarlos, pero esto puede presentar efectos secundarios ocultos.
Cuando se hereda de una clase, se está heredando de toda una jerarquía de clases cuya estructura interna puede ser desconocida. Cualquier llamada a un método puede ocultar un complejo conjunto de operaciones en toda la jerarquía de clases que afecte al conjunto de la aplicación. Por eso es aconsejable llamar explícitamente a la implementación del padre.
En la llamada a la implementación original podemos establecer dos tipos de filtros. Un prefiltro sobre los argumentos de la llamada y un postfiltro sobre los resultados de la llamada.
Se aconseja realizar siempre la llamada al método de la superclase, pero, todo tiene un pero, hay ocasiones en que los efectos secundarios del método de la superclase es lo que se quiere evitar, sustituyéndolo por un método en el hijo que evite esos efectos del método original.
Y un último consejo, si se llama a la implementación original de un método es conveniente hacerlo tan pronto como se disponga de los datos necesarios para hacerlo.
2.2.2 Herencia múltiple
Una clase puede derivarse de más de una clase base en Python. Es lo que se denomina herencia múltiple.
En la herencia múltiple, las características de todas las clases base se heredan en la clase derivada.
La sintaxis de la herencia múltiple es similar a la de la herencia simple.
class <nombre_clase>(<clase_base>[, <clase_base>]):
<declaraciones>
En el caso de herencia múltiple la búsqueda de los atributos heredados de las clases padres se realiza en profundidad, de izquierda a derecha. Esta regla se aplica recursivamente si la clase base deriva a su vez de alguna otra clase.
2.2.2.1 Orden de resolución de métodos (MRO)
El orden de resolución de métodos (Method Resolution Order - MRO) es el orden en el que los métodos deben ser heredados en el caso de una herencia múltiple. Asegura que una clase siempre aparezca antes que sus progenitores y en caso de que haya varios progenitores, el orden es el mismo que el de una tupla de clases base, de izquierda a derecha. La secuencia MRO puede visualizarse con el atributo __mro__.
>>> class A: ... pass >>> class B: ... pass >>> class C: ... pass >>> class X(A, B): ... pass >>> class Y(B,C): ... pass >>> class Z(Y, X, C): ... pass >>> X.__mro__ (<class '__main__.X'>, <class '__main__.A'>, <class '__main__.B'>, <class 'object'>) >>> Y.__mro__ (<class '__main__.Y'>, <class '__main__.B'>, <class '__main__.C'>, <class 'object'>) >>> Z.__mro__ (<class '__main__.Z'>, <class '__main__.Y'>, <class '__main__.X'>, <class '__main__.A'>, <class '__main__.B'>, <class '__main__.C'>, <class 'object'>)
Como podemos ver todas las clases terminan derivando de la clase object. En Python 3 no hace falta especificarlo. En versiones anteriores las clases A, B y C deben heredar de object.
El problema del diamante describe una ambigüedad que surge cuando dos clases B y C heredan de una superclase A, y otra clase D hereda tanto de B como de C. Si hay un método m en A que B o C o ambos han reemplazado, e incluso si no lo anulan, ¿qué versión del método heredaría D?
Si todas las superclases disponen del método m(), se aplicará el orden de resolución de métodos (MRO), así:
>>> class A:
... def m(self):
... print("Clase A")
>>> class B(A):
... def m(self):
... print("Clase B")
>>> class C(A):
... def m(self):
... print("Clase C")
>>> class D(B,C):
... pass
>>> d = D()
>>> d.m()
Clase B
Vemos que se hereda el método de la clase B. Si cambiamos el orden de herencia en la clase D:
>>> class D(C, B):
... pass
>>> d = D()
>>> d.m()
Clase C
Entonces MRO pone a disposición el método de la clase C.
Pero si el escenario cambia y solo una de las superclases B o C reemplaza el método de la clase A, vemos que:
>>> class A:
... def m(self):
... print("Clase A")
>>> class B(A):
... pass
>>> class C(A):
... def m(self):
... print("Clase C")
>>> class D(B,C):
... pass
>>> d = D()
>>> d.m()
Clase C
D.__mro__
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
El orden de resolución de métodos nos marca el orden en el que los métodos son heredados en el caso de una herencia múltiple. Como bien podemos ver con el atributo __mro__, se buscaría el método m() en la clase D; al no encontralo pasa a la siguiente en la lista, la clase B, donde tampoco lo encuentra, la siguiente en la lista es la clase C y ahí si encuentra un método m(), con lo que ya no llega a la clase A.
2.3 Encapsulación
La encapsulación en orientación a objeto oculta los detalles de cómo está implementada la clase y restringe el acceso a los atributos y métodos. Esto evita que los datos se manipulen desde el exterior directamente, ofreciendo métodos para realizar ese servicio. La encapsulación es un mecanismo de control. El estado de un objeto sólo debe ser modificado por medio de los métodos del propio objeto.
En la mayoría de los lenguajes orientados a objetos se utilizan modificadores de acceso a los miembros de una clase: privado, público y protegido (private, public, protected). Los modificadores de acceso desempeñan un papel importante para proteger los datos de un acceso no autorizado.
2.3.1 Modificadores de acceso
En Python no hay miembros privados de instancia. Todos los miembros de la clase son públicos. Sin embargo, hay una convención en Python que hace uso de guiones bajos ( _ ) para especificar el modificador de acceso para un miembro específico en una clase.
Los modificadores de acceso para una clase en Python establecen el nivel de acceso a los miembros de la clase.
- Acceso público. Los miembros de la clase son accesibles desde fuera de la clase. Por defecto, todos los atributos y métodos de una clase son públicos.
- Acceso protegido. Son accesibles desde fuera de la clase pero sólo en una clase derivada de ella. El modificador de acceso establece un guión bajo como prefijo ( _ ) al nombre del miembro para que esté protegido.
- Acceso privado . Sólo son accesibles desde dentro de la clase. El modificador de acceso establece un doble guión bajo como prefijo ( __ ) al nombre del miembro para que se convierta en privado.
Los atributos y métodos privados no están realmente ocultos, cualquier identificador que empiece por dos guiones bajos es textualmente reemplazado por:
_<nombre_clase>__<identificador>
Donde nombre_clase es el nombre de clase actual al que se le eliminan los guiones bajos del comienzo si los tuviera. Se modifica el nombre del identificador sin importar su posición sintáctica, siempre y cuando ocurra dentro de la definición de una clase.
Si volvemos al ejemplo del apartado Ámbito de las variables, podemos realizar el cambio de la variable punto_inicial por una variable privada de instancia de la forma:
>>> class Punto: ... __punto_inicial = [0, 0] ... ... # constructor ... def __init__(self, x, y): ... self.x = x ... self.y = y ... self.__punto_inicial[0] = x ... self.__punto_inicial[1] = y ... ... def get_punto_inicial(self): ... return self.__punto_inicial
Y podemos hacer.
>>> p1 = Punto(1, 2)
>>> # se lanzaría un AttributeError si hacemos:
>>> # print(p1.__punto_inicial)
>>> # pero sí funcionaría lo siguiente
>>> p = p1.get_punto_inicial()
>>> print(f"Punto inicial p1 = ({p[0]}, {p[1]}")
Punto inicial p1 = (1, 2)
Si conocemos el nombre de la variable privada la podríamos referenciar antecediendo el nombre de la clase precedido de un guión bajo. Así:
>>> print(p1._Punto__punto_inicial)
[1, 2]
PEP 8: Herencia
Decida siempre si los métodos y los atributos de instancia de una clase deben ser públicos o no públicos. En caso de duda es mejor hacerlos no públicos. Hacerlos públicos posteriormente es más sencillo que lo contrario.
2.4 Polimorfismo
El polimorfismo es la sobrecarga de métodos, entendiendo como tal la capacidad del lenguaje de determinar qué método ejecutar de entre varios métodos con igual nombre según el tipo o número de los parámetros que se le pasa.
En Python no existe sobrecarga de métodos, ya que cada nuevo método definido sobrescribiría la implementación del anterior, por la definición de herencia de Python, pero es posible simular el comportamiento de sobrecarga en Python empleando parámetros por defecto.
En nuestro ejemplo si hacemos.
>>> class Punto:
... # constructor
... def __init__(self, x=0, y=0):
... self.x = x
... self.y = y
...
... def __repr__(self):
... return f"({self.x}, {self.y})"
Podemos crear objetos de tipo Punto de las siguientes formas.
>>> p1 = Punto()
>>> p1
(0, 0)
>>> p2 = Punto(1)
>>> p2
(1, 0)
>>> p3 = Punto(1, 2)
>>> p3
(1, 2)
>>> p4 = Punto(y=2)
>>> p4
(0, 2)
2.5 Propiedades
Sobre los atributos de una clase generalmente se realizan operaciones de actualización o consulta
Una forma de operar, más o menos estándar, es crear métodos para establecer u obtener el contenido de los atributos, los llamados setter y getter respectivamente.
En Python se puede enmascarar el método mediante un alias con un atributo de datos que establece una propiedad. Las propiedades se llaman automáticamente cuando se intenta cambiar o tomar el valor del atributo en sí.
Cuando los métodos están enmascarados por una propiedad suele hacerse uso de la convención de anteceder los nombres con un guión bajo, para indicar que están protegidos y que no están destinados a ser utilizados directamente. Esto no tiene ningún significado especial para el lenguaje, es tan sólo una convención.
La función integrada property() nos permite establecer atributos de propiedad en una clase.
property(fget=None, fset=None, fdel=None, doc=None)
Donde:
- fget es la función para obtener el valor del atributo.
- fset es la función para establecer el valor del atributo.
- fdel es la función para borrar el atributo.
- doc es una cadena (como un comentario).
Los atributos suelen definirse en la instancia de la clase a través del método constructor __init__(), mientras que las propiedades son parte de la propia clase.
En el siguiente ejemplo al atributo nombre le antecede un guión bajo para indicar que es de acceso protegido.
>>> class Propiedad:
... def __init__(self, nombre):
... self._nombre = nombre
...
... # función para obtener el valor del atributo
... def _get_nombre(self):
... print('Obtener valor')
... return self._nombre
...
... # función para establecer el valor del atributo
... def _set_nombre(self, valor):
... print('Establecer valor')
... self._nombre = valor
...
... # función para eliminar el atributo
... def _del_nombre(self):
... print('Eliminar')
... del self._nombre
...
... # creación de los atributos de propiedad
... nombre = property(_get_nombre, _set_nombre, _del_nombre)
>>> x = Propiedad('Python')
>>> # leemos el valor
>>> # se llamará al 'getter': _get_nombre
>>> x.nombre
Obtener valor
'Python'
>>> # modificamos el valor
>>> # se llamará al 'setter': _set_nombre
>>> x.nombre = 'Magic'
Establecer valor
>>> x.nombre
'Magic'
>>> # eliminamos el valor
>>> # se llamará a: _del_nombre
>>> del x.nombre
Eliminar
>>> x.nombre
Obtener valor
Traceback (most recent call last):
. . .
AttributeError: 'Propiedad' object has no attribute '_nombre'
Python ofrece la posibilidad de emplear el decorador @property para simplificar el código. Esto también nos facilita el emplear el mismo nombre del atributo para definir las funciones setter y deleter, y así no inflar la clase con multitud de nombres de métodos.
- @property define la propiedad correspondiente para obtener (getter) el atributo.
- @<propiedad>.setter define la propiedad para establecer (setter) el valor del atributo.
- @<propiedad>.deleter define la propiedad para borrar atributo.
Modificamos el ejemplo anterior, donde se han eliminado los textos a visualizar en la ejecución de cada método, y se ha precedido al atributo nombre con dos guiones bajos para indicar que es de acceso privado.
>>> class Propiedad:
... def __init__(self, nombre):
... self.__nombre = nombre
...
... # función para obtener el valor del atributo
... @property
... def nombre(self):
... return self.__nombre
...
... # función para establecer el valor del atributo
... @nombre.setter
... def nombre(self, valor):
... self.__nombre = valor
...
... # función para borrar el atributo
... @nombre.deleter
... def nombre(self):
... del self.__nombre
>>> x = Propiedad('Python')
>>> x.nombre
'Python'
>>> x.nombre = 'Magic'
>>> x.nombre
'Magic'
>>> del x.nombre
>>> x.nombre
Traceback (most recent call last):
. . .
AttributeError: Propiedad' object has no attribute ' _Propiedad__nombre
Observar que en el mensaje de error el atributo aparece con la sintaxis de los atributos privados: _nombreClase__nombreAtributo.
No necesariamente han de definirse los tres métodos para cada propiedad. Dependerá del contexto y las necesidades de la aplicación.
2.6 Métodos de clase y métodos estáticos
Hasta ahora hemos visto métodos de instancia. Es el tipo de método básico que usaremos al crear un objeto cuando instanciemos una clase. Este tipo de métodos lleva un primer parámetro, self, que apunta a una instancia de la clase cuando se llama al método.
A través del parámetro self, los métodos de instancia acceden libremente a los atributos y métodos del mismo objeto. Todo esto les permite modificar el estado del objeto.
Disponemos de otros tipos de métodos, los métodos de clase y los estáticos.
- Los métodos de clase (classmethod) llevan un primer parámetro cls, que apunta a la clase y no a la instancia del objeto cuando se llama al método. Debido a que el método de clase sólo tiene acceso a la clase vía cls, no puede modificar el estado de la instancia del objeto. Eso requeriría el acceso a self. Sin embargo, los métodos de clase aún pueden modificar el estado de la clase que se aplica a todas las instancias de la clase. El método de la clase es accesible tanto por la clase como por su objeto.
- Los métodos estáticos (staticmethod) no llevan ni parámetro self ni cls como primer parámetro. Por lo tanto, un método estático no puede modificar el estado del objeto ni el estado de la clase. Los métodos estáticos están restringidos en cuanto a los datos a los que pueden acceder, actúa como una función regular que pertenece al espacio de nombres de la clase. No sabe nada de la clase y sólo se ocupa de los parámetros.
Hay dos maneras de crear métodos de clase y estáticos en Python:
- La función classmethod(<función>)
- Con el decorador @classmethod
- La función staticmethod(<función>)
- Con el decorador @staticmethod
Vamos a ver su funcionamiento con un ejemplo.
>>> class Fecha(object):
... def __init__(self, aaaa=0, mm=0, dd=0):
... self.dd = dd
... self.mm = mm
... self.aaaa = aaaa
...
... def __repr__(self):
... return f"{self.aaaa}-{self.mm}-{self.dd}"
...
... # método de clase, lleva como primer parámetro cls, la clase
... @classmethod
... def fcadena(cls, fecha_cadena):
... aaaa, mm, dd = map(int, fecha_cadena.split('-'))
... return cls(aaaa, mm, dd)
...
... # método estático, no lleva ni self ni cls
... @staticmethod
... def fclasica(dd, mm, aaaa):
... return Fecha(aaaa, mm, dd)
>>> # crear objeto con constructor
>>> fecha1 = Fecha(2022, 3, 1)
>>> fecha1
2022-3-1
>>> # crear objeto con método de clase
>>> fecha2 = Fecha.fcadena('2022-03-02')
>>> fecha2
2022-3-2
>>> # crear objeto con método estático
>>> fecha3 = Fecha.fclasica(3, 3, 2022)
>>> fecha3
2022-3-3
>>> isinstance(fecha1, Fecha)
True
>>> isinstance(fecha2, Fecha)
True
>>> isinstance(fecha3, Fecha)
True
Creamos un primer objeto de tipo Fecha que hace uso del constructor.
Después llamamos a un método de clase, que lleva como primer parámetro la clase, e internamente hace uso del constructor, pues cuando usamos el objeto llama al método de representación que nos visualiza la fecha con la que hemos llamado al método.
A continuación llamamos al método estático que nos devuelve un objeto de tipo Fecha, pues en el return llama a la clase, que hará uso del constructor.
Finalmente verificamos que los tres objetos sean instancias de Fecha, lo que es correcto en los tres casos.
Vamos a ver ahora que ocurre cuando empleamos una clase que hereda de la clase Fecha
>>> class FechaHija(Fecha):
... def __repr__(self):
... return f"FHija: {self.aaaa}-{self.mm}-{self.dd}"
>>> fecha1 = FechaHija(2022, 3, 1)
>>> fecha1
FHija: 2022-3-1
>>> fecha2 = FechaHija.fcadena('2022-03-02')
>>> fecha2
FHija: 2022-3-2
>>> fecha3 = FechaHija.fclasica(3, 3, 2022)
>>> fecha3
2022-3-3
Vemos que el objeto fecha3 no es una instancia de la clase FechaHija, ya que la representación al llamar al objeto se corresponde con la de la clase base. Esto se debe a que fclasica es un método estático. Bastaría con cambiar el decorador del método a @classmethod para resolverlo.
Deben utilizarse métodos de clase cuando no se necesita la información de la instancia, pero se necesita la información de la clase tal vez para otra clase o métodos estáticos, o tal vez para sí mismo como constructor. También permite cambiar el comportamiento del método basado en la subclase que lo llama, pues tenemos una referencia a la clase que llama en el atributo __name__ de la clase.
Deben utilizarse métodos estáticos cuando no se necesiten los argumentos de clase o instancia, pero la función está relacionada con el uso del objeto, y es conveniente que la función esté en el espacio de nombres del objeto. Además, de esta forma el comportamiento permanece sin cambios a través de las subclases.
2.7 Estructuras de datos
Python no tiene un tipo de datos para crear estructuras, pero una clase vacía, junto a la creación dinámica de atributos o una lista de atributos, nos proporciona el mismo resultado.
Los atributos de los objetos se almacenan en un atributo incorporado a la clase, el diccionario __dict__. El diccionario utilizado para almacenar los atributos puede modificarse, como cualquier diccionario. El poder añadir y eliminar elementos, es la razón por la que se pueden crear y eliminar atributos dinámicamente en los objetos de las clases.
>>> class Nota:
... pass
>>> # crear un objeto vacío de la clase Nota()
>>> apunte = Nota()
>>> # crear/llenar los campos de la nota
>>> apunte.de = 'Sancho Panza'
>>> apunte.para = 'Don Quijote'
>>> apunte.mensaje = 'Son molinos!'
>>> apunte.__dict__
{'de': 'Sancho Panza', 'para': 'Don Quijote', 'mensaje': 'Son molinos!'}
El uso de un diccionario para el almacenamiento de atributos puede representar un desperdicio de espacio para los objetos, en el caso de herencias, y sobre todo cuando tienen que crearse un gran número de instancias.
Una forma de resolver este problema es mediante un espacio (slot) estático donde definir únicamente los atributos que se vayan a utilizar, para ello hay que crear una lista de nombre __slots__ que contendrá la relación de atributos.
Modificaremos la clase anterior para establecer la lista con los nombres de los atributos que necesitaremos.
>>> class Nota:
... __slots__ = ['de', 'para', 'mensaje']
>>> # crear un objeto de la clase Nota()
>>> apunte = Nota()
>>> # rellenar los campos de la nota
>>> apunte.de = 'Sancho Panza'
>>> apunte.para = 'Don Quijote'
>>> apunte.mensaje = 'Son molinos!'
>>> apunte.__slots__
['de', 'para', 'mensaje']
>>> # no se admiten otros campos
>>> apunte.urgencia = 'alta'
Traceback (most recent call last):
. . .
AttributeError: 'Nota' object has no attribute 'urgencia'
El objeto que creemos ya no dispone del diccionario __dict__, por lo que no admite la creación dinámica de atributos, y tan solo podemos crear los que aparecen en la lista __slots__.
2.8 Atributos incorporados
Las clases de Python mantienen un conjunto de atributos incorporados, a los que se puede acceder usando el operador punto ( . ), como a cualquier otro atributo.
| Atributo | Descripción |
|---|---|
| __dict__ | Diccionario que contiene el espacio de nombres de la clase. |
| __doc__ | Cadena de documentación de la clase o ninguna, si no está definida. |
| __name__ | Nombre de la clase. |
| __module__ | Nombre del módulo en el que se define la clase. Este atributo es '__main__' en modo interactivo. |
| __bases__ | Tupla que contiene las clases base, en el orden de su aparición en la lista de clases base. |
Vamos a verlos en el siguiente ejemplo.
>>> class Clase:
... '''Clase de prueba de atributos incorporados'''
...
... def __init__(self):
... print('Atributos incorporados')
>>> Clase.__doc__
'Clase de prueba de atributos incorporados'
>>> Clase.__dict__
mappingproxy({'__module__': '__main__', '__doc__': 'Clase de prueba de atributos incorporados', '__init__': <function Clase.__init__ at 0x000001BB60BF5400>, '__dict__': <attribute '__dict__' of 'Clase' objects>, '__weakref__': <attribute '__weakref__' of 'Clase' objects>})
>>> Clase.__name__
'Clase'
>>> Clase.__module__
'__main__'
>>> Clase.__bases__
(<class 'object'>,)
2.9 Métodos especiales
Los métodos especiales son un conjunto de métodos predefinidos que se puede usar para enriquecer las clases. Todos ellos comienzan y terminan con guiones bajos dobles. Se los conoce como dunders, una abreviatura de double underline (doble subrayado).
Ya vimos al principio del capítulo el uso del método __init__.
Entre los métodos más empleados tenemos:
| Método | Descripción |
|---|---|
| __new__(cls[, args]) | Método exclusivo de las clases que se ejecuta antes que __init__ y que se encarga de construir y devolver el objeto en sí. El primer parámetro es la clase, los demás argumentos se pasan a __init__. |
| __init__(self [, args]) | Inicializador de la clase. Se llama después de crear el objeto para realizar tareas de inicialización. |
| __del__(self) | Destructor. Se utiliza para realizar tareas de limpieza. |
| __repr__(self) | Devuelve una cadena de texto con la representación oficial del objeto.
Este método se llama cuando se invoca la función repr() en el objeto. |
| __str__(self) | Devuelve una cadena de texto con la representación informal del objeto.
Este método se llama cuando se invoca la función print() o str() en el objeto. Cuando una clase no implementa el método __str__, Python devuelve el resultado del método __repr__. |
| __len__(self) | Devuelve la longitud del objeto. |
| __getitem__(self, key) | Implementa la evaluación de la selección por clave self[key]. Para los tipos de secuencia, las claves aceptadas deben ser enteros. |
| __setitem__(self, key, value) | Implementa la asignación del valor a self[key]. Sólo debe implementarse si los objetos soportan cambios en los valores de las claves, si se pueden añadir nuevas claves, o si se pueden reemplazar elementos. |
| __delitem__(self, key) | Implementa la eliminación por clave self[key]. Sólo debe implementarse si los objetos soportan la eliminación de claves, o para secuencias si los elementos pueden ser eliminados de la secuencia. |
| __iter__(self) | Método a emplear cuando se requiere un iterador para un contenedor. Este método debe devolver un nuevo objeto iterador que pueda iterar sobre todos los objetos del contenedor. |
| __next__(self) | Devuelve el siguiente elemento de la secuencia. Al llegar al final, y en las llamadas posteriores, debe lanzar una excepción StopIteration. |
| __enter__(self) | Inicia el proceso de un gestor de contexto. |
| __exit__(self, exc_type, exc_value, traceback) | Finaliza el gestor de contexto. |
Disponemos también de los métodos que nos permiten sobreescribir operadores aritméticos para realizar operativas específicas de la clase con los operadores clásicos.
| Métodos aritméticos | Operadores aritméticos |
|---|---|
| __add__(self, other)
__sub__(self, other) __mul__(self, other) __floordiv__(self, other) __div__(self, other) __mod__(self, other) __pow__(self, other[, modulo]) __rshift__(self, other) __and__(self, other) __xor__(self, other) __or__(self, other) | +
- * // / % ** > & ^ | |
Igualmente podemos sobreescribir los operadores unarios haciendo uso de los métodos especiales:
| Métodos unarios | Operadores unarios |
|---|---|
| __neg__(self)
__pos__(self) __invert__(self) | -
+ ~ |
Los operadores de asignación ampliada (augmented assignment) también disponen de los métodos especiales para su sobreescritura.
| Métodos para los operadores de asignación | Operadores de asignación |
|---|---|
| __iadd__(self, other)
__isub__(self, other) __imul__(self, other) __idiv__(self, other) __ifloordiv__(self, other) __imod__(self, other) __ipow__(self, other[, modulo]) __irshift__(self, other) __iand__(self, other) __ixor__(self, other) __ior__(self, other) | +=
-= *= /= //= %= **= >= &= ^= |= |
Y también, cómo no, los operadores relacionales.
| Métodos relacionales | Descripción |
|---|---|
| __lt__(self, other)
__le__(self, other) __eq__(self, other) __ne__(self, other) __gt__(self, other) __ge__(self, other) | x<y ⇒ x.__lt__(y)
x<=y ⇒ x.__le__(y) x==y ⇒ x.__eq__(y) x!=y ⇒ x<>y x.__ne__(y) x>y ⇒ x.__gt__(y) x>=y ⇒ x.__ge__(y) |
En las secciones siguientes vamos a ver alguna de sus aplicaciones.
2.9.1 Destrucción de objetos (Recogida de basura)
Python borra los objetos innecesarios automáticamente para liberar el espacio de memoria. El proceso por el cual Python recupera periódicamente bloques de memoria que ya no se utilizan se denomina recolección de basura.
El recolector de basura de Python actúa durante la ejecución del programa y se activa cuando la cuenta de referencia de un objeto llega a cero. La cuenta de referencia de un objeto cambia a medida que cambia el número de alias que lo apuntan.
El recuento de referencias de un objeto aumenta cuando se le asigna un nuevo nombre o se coloca en un contenedor (lista, tupla o diccionario). El recuento de referencias de un objeto disminuye cuando se borra con del, se reasigna su referencia o su referencia se sale del ámbito de aplicación. Cuando el recuento de referencias de un objeto llega a cero, Python lo recolecta automáticamente.
El método __del__() se ejecuta cuando una instancia de la clase está a punto de ser destruida, lo que nos permite actuar si fuera necesario.
Veamos un ejemplo:
>>> class Punto:
... # constructor
... def __init__(self, x, y):
... self.x = x
... self.y = y
...
... def __del__(self):
... nombre_clase = self.__class__.__name__
... print(nombre_clase, "destruido")
>>> p1 = Punto(1, 2)
>>> del p1
Punto destruido
2.9.2 Iteradores
Dado que la mayoría de los objetos contenedores pueden ser recorridos en Python usando una sentencia for, parece lógico que añadamos comportamiento iterador a nuestras clases.
Un objeto es iterable si se pueden recorrer todos sus valores.
Técnicamente, en Python, un iterador es un objeto que implementa un protocolo iterador basado en dos métodos especiales: __iter__() y __next__(). Básicamente la sentencia for llama a iter() en el objeto contenedor. La función devuelve un objeto iterador que define el método __next__(), que accede a los elementos en el contenedor de uno en uno. Cuando no hay más elementos, __next__() lanza una excepción StopIteration que le avisa al bucle de la finalización. Usando el bucle for podemos iterar sobre cualquier objeto que nos devuelva el iterador.
Vamos a ver primero el funcionamiento de las funciones integradas iter() y next() sobre una cadena.
>>> cadena = '123'
>>> iterador = iter(cadena)
>>> next(iterador)
'1'
>>> next(iterador)
'2'
>>> next(iterador)
'3'
>>> next(iterador) # excepción StopIteration
Traceback (most recent call last):
. . .
StopIteration
Para incluir un iterador en una clase debemos implementar los métodos __iter__() y __next__() en la clase.
El método __iter__() devuelve el objeto iterador en sí. Si es necesario, se puede realizar una inicialización.
El método __next__() debe devolver el siguiente elemento de la secuencia. Al llegar al final, y en las llamadas posteriores, debe lanzar una excepción StopIteration.
Ahora estableceremos los métodos __iter__() y __next__() en una clase.
>>> class Cadena:
... def __init__(self, cadena):
... self.cadena = cadena
...
... def __iter__(self):
... self.indice = -1
... return self
...
... def __next__(self):
... self.indice += 1
... if self.indice == len(self.cadena):
... raise StopIteration
... return self.cadena[self.indice].upper()
>>> cadena = Cadena('Hola')
>>> for c in cadena:
... print(c)
H
O
L
A
Vemos que el bucle for se detiene cuando le llega la excepción StopIteration.
2.9.3 Gestor de contexo
Un gestor de contexto permite aplicar un conjunto de acciones a la entrada y a la salida del bloque de código que engloba. Es un mecanismo de preparación y liberación de los recursos de forma automática
Cuando se crean gestores de contexto utilizando clases, se deben implementar los métodos __enter__() y __exit__().
El método __enter__() devuelve el recurso que debe gestionarse.
El método __exit__() se encarga de liberar los recursos utilizados y se ejecutará cuando se terminen de usar dichos recursos. Si se lanza una excepción, el método recibe el tipo, valor y traza de la excepción como argumentos. Si el contexto sale normalmente, los tres argumentos serán None. Si se suprime la excepción, el valor de retorno del método __exit__() será True, en caso contrario, False.
Vamos a crear un gestor de contexto que realizaría una conexión y la finalizaría al salir del contexto.
>>> class Conecta:
... def __init__(self, direccion):
... self.direccion = direccion
...
... def __enter__(self):
... print(f'Inicio gestor para {self.direccion}')
...
... def __exit__(self, exc_type, exc_value, exc_traceback):
... print(f'Salida gestor para {self.direccion}')
>>> with Conecta('BD') as enlace:
... print('Proceso en gestor de contexto')
Inicio gestor para BD
Proceso en gestor de contexto
Salida gestor para BD
Observamos que se ejecuta el proceso establecido en el método __init__() al preparar el contexto en el with, y al terminar se ejecuta el proceso del método __exit__().
2.9.4 Sobrecarga de operadores
La sobrecarga de operadores nos permite ampliar las capacidades de los lenguajes de programación orientados a objetos. Mediante la sobrecarga, un mismo operador puede realizar múltiples operaciones sobre distintos tipos de datos. Así, el operador + realizará la suma algebraica si son dos números, o la concatenación si son dos cadenas de texto.
A través de las clases creadas o sobrecargadas por el usuario se pueden modificar casi todos los métodos de operadores incorporados de Python. Estos métodos se identifican con los nombres del operador con dos guiones bajos como prefijo y otros dos como sufijo. Como hemos visto en la tabla de Métodos especiales, el operador suma ( + ) es __add__. Cuando el interprete de Python evalúa el operador +, si la clase de usuario ha sobrecargado el método __add__(), se empleará el método del usuario en lugar del método incorporado de Python.
Vamos a aplicar la sobrecarga a los operadores suma ( + ) y resta ( - ) en nuestra clase Punto. Para ello emplearemos los métodos especiales __add__ y __sub__, además de los de asignación ampliada __iadd_ y __isub__.
>>> class Punto:
... # constructor
... def __init__(self, x, y):
... self.x = x
... self.y = y
...
... def __repr__(self):
... return f"({self.x}, {self.y})"
...
... def __add__(self, otro):
... # devuelve un punto que es la suma de dos puntos
... return Punto(self.x + otro.x, self.y + otro.y)
...
... def __sub__(self, otro):
... # devuelve un punto que es la diferencia de ambos puntos
... return Punto(self.x - otro.x, self.y - otro.y)
...
... def __iadd___(self, otro):
... # devuelve el punto incrementado
... self.x += otro.x
... self.y += otro.y
... return Punto(self.x, self.y)
...
... def __isub___(self, otro):
... # devuelve el punto decrementado
... self.x -= otro.x
... self.y -= otro.y
... return Punto(self.x, self.y)
Ahora podemos sumar y restar puntos con los operadores + y -.
>>> p1 = Punto(1, 2)
>>> p1
(1, 2)
>>> p2 = Punto(3, 4)
>>> p2
(3, 4)
>>> p3 = p1 + p2
>>> p3
(4, 6)
>>> p3 -= p1
>>> p3
(3, 4)
PEP 8: Sobrecarga de operadores
Al implementar la sobrecarga de los operadores relacionales, es mejor implementar las seis operaciones (__eq__, __ne__, __lt__, __le__, __gt__, __ge__) en lugar de confiar en otro código para realizar una comparación en particular.