Clases#

Como ya hemos visto cuando hemos tratado con los predefinidas de Python (cadenas, enteros, booleanos etc), una clase es una forma de encapsular un conjunto de datos o atributos más una serie de métodos que nos permiten gestionar estos datos e interactuar con el resto del programa.

Para definir una clase en Python, utilizamos el comando class. Por ejemplo, imaginemos que queremos trabajar con puntos del plano en nuestro programa. Sería interesante crear una clase como la siguiente

import math

class Punto:
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def distancia_al_origen(self):
        dist = math.sqrt(self.x**2 + self.y**2)
        return dist

Al definir una clase, estamos describiendo una estructura o patrón que queremos seguir a la hora de crear instancias de la clase. En la definición de la clase, self hace referencia al propio objeto. Siempre es el primer argumento de los métodos que se definen en la clase. Este primer argumento no se hará explícito cuando se use el método, sino que toma su valor del objeto sobre el que se evalúa el método.

El método __init__ es un método especial que define cómo se construyen inicialmente los objetos de la clase. En el ejemplo anterior, el constructor recibe como argumentos de entrada dos valores xe y (por defecto con valor 0). Esos valores se almacenan respectivamente en los atributos de la clase x e y (que en este caso tienen el mismo nombre, pero no necesariamente tiene que ser así).

Siempre que creemos una instancia de la clase, llamaremos al método __init__.

Para crear objetos de un clase, simplemente se llama al nombre de la clase junto con los argumentos de entrada al constructor (excepto el primer argumento, self). Lo que sige define un objeto de la clase Punto y lo asigna a una variable p:

p = Punto(2,3)

En general, si tenemos un objeto o y uno de sus atributos atr, para acceder al valor de ese atributo en el objeto lo hacemos con la notación o.atr. Si se trata de un método f, se aplica con la notación o.f(...), proporcionando los argumentos de entrada que se han definido para el método (excepto el primer argumento, que no se proporciona ya que es el propio objeto). También existen funciones como

  • getattr

  • setattr

  • delattr

que aceptan como primer parámetro un objeto y como segundo una cadena con el nombre del atributo.

p.x
2
p.y
3
p.distancia_al_origen()
3.605551275463989

La función dir(obj) nos devuelve el conjunto de métodos disponibles para un objeto.

dir(p)
['__class__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 '__weakref__',
 'distancia_al_origen',
 'x',
 'y']

Exercise 38

Crea una clase Perro que tenga los atributos nombre y edad y los siguientes métodos:

  • ladra: escribe un mensaje por pantalla.

  • crece: aumenta la edad del perro un uno.

  • __str__: para representar adecuadamente una instancia de Perro como cadena.


Métodos especiales#

Hay otros métodos especiales que se pueden definir en todas las clases, y que tienen nombres especiales reservados. No es obligatorio definirlos, pero si se definen en la clase tienen una finalidad específica. Tienen sus nombres entre dobles guinoes bajos, como __eq__, que sirve para definir cómo se comparan dos objetos de la clase; o __str__, que sirve para proporcionar una representación de un objeto de la clase en forma de cadena.

import math

class PuntoV2():
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def distancia_al_origen(self):
        dist = math.sqrt(self.x**2 + self.y**2)
        return dist

    def __eq__(self,punto):
        return self.x == punto.x and self.y == punto.y

    def __str__(self):
        return f"Punto(x = {self.x:.3f}, y = {self.y:.3f})"
a = Punto(1, 1)
p = PuntoV2(3, 4)
q = PuntoV2(2, 5)
r = PuntoV2(3, 4)
print(a)
<__main__.Punto object at 0x7daf29671450>
print(p)
Punto(x = 3.000, y = 4.000)
a == p
False
p == r
True

Exercise 39

Crea la clase PointV3 copiando el código de PointV2 y añadiendo el método especial __add__ que nos permita sumar puntos del plano. Añade otro método que incluya un parámetro p para calcular la norma p de un punto. Crea otro método dot que implemente el producto escalar de los vectores correspondientes.

class PuntoV3():
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def distancia_al_origen(self):
        dist = math.sqrt(self.x**2 + self.y**2)
        return dist

    def __eq__(self, punto):
        return self.x == punto.x and self.y == punto.y

    def __str__(self):
        return f"Punto(x = {self.x:.3f}, y = {self.y:.3f})"

    def __add__(self, punto):
        return PuntoV3(self.x + punto.x, self.y + punto.y)

    def normap(self, p):
        norm = (self.x**p + self.y**p)**(1/p)
        return norm

    def dot(self, punto):
        return self.x*punto.x + self.y*punto.y
p = PuntoV3(1, 1)
q = PuntoV3(2, 4)
print(p.normap(p=3))
print(p.normap(p=2) == p.distancia_al_origen())
print(p + q)
print(p.dot(q))
1.2599210498948732
True
Punto(x = 3.000, y = 5.000)
6

Subclases y herencia#

La herencia es un mecanismo para crear nuevas clases que especializan o modifican el comportamiento de una clase ya existente. Cuando una clase se crea vía herencia, hereda los atributos y los métodos de la clase base, con la posibilidad de ampliarlos, modificarlos o redefinirlos.

La herencia se especifica incluyendo entre parántesis las clases base de las que queremos heredar en el enunciado class. Por ejemplo podemos crear una clase Circulo que herede de punto

class Circulo(PuntoV2):
    def __init__(self, radio=1, x=0, y=0):
        super().__init__(x, y)
        self.radio = radio

    def distancia_al_origen(self):
        dist = abs(super().distancia_al_origen() - self.radio)
        return dist

    def calcula_area(self):
        area = 2 * math.pi * self.radio**2
        return area

    def __eq__(self, circulo):
        son_iguales = (
            self.x == circulo.x and
            self.y == circulo.y and
            self.radio == self.radio
        )
        return son_iguales

    def __str__(self):
        return f"Circulo (x = {self.x:.3f}, y = {self.y:.3f}, radio = {self.radio:.3})"
circulo = Circulo()
circulo.radio
1
circulo.distancia_al_origen()
1.0

En este ejemplo, estamos accediendo a los métodos de la clase base mediante la función super(), en particular estamos creando los atributos correspondiente a la clase PuntoV2 más el nuevo atributo radio. También estamos redefiniendo los métodos distancia_al_origen, __eq__ y __str__. Finalmente, añadimos un nuevo método calcula_area, que no pertenece a la clase Punto.

Exercise 40

Escribe una clase Cuenta que represente una cuenta bancaria. Dicha clase tendrá los siguientes atributos

  • nombre

  • deposito

Incluye métodos para depositar y retirar cantidades. Incluye otro método retira_todo para retirar todo el dinero de la cuenta.

Escribe una clase hija CuentaInversion con un atributo más llamado riesgo en la que el método retira_todo multiplica el depósito por un número tomado de una distribución normal de media uno y desviación estándar riesgo (véase el módulo estándar de Python random para números pseudoaleatorios)

class Cuenta:
    def __init__(self, nombre, deposito):
        self.nombre = nombre
        self.deposito = deposito

    def deposita(self, cantidad):
        self.deposito += cantidad

    def retira(self, cantidad):
        self.deposito -= cantidad

    def interes(self, tipo_de_interes=0.1):
        self.deposito *= (1 + tipo_de_interes)

    def retira_todo(self):
        cantidad = self.deposito
        self.deposito = 0
        return cantidad
cuenta = Cuenta("Pepito", 1000)

print(cuenta.nombre)
print(cuenta.deposito)

print("Depositando...")
cuenta.deposita(50)
print(cuenta.deposito)

print("Retirando...")
cuenta.retira(100)
print(cuenta.deposito)

print("Interes...")
cuenta.interes()
print(cuenta.deposito)

print("Retirando todo...")
total = cuenta.retira_todo()
print(cuenta.deposito)
print(total)
Pepito
1000
Depositando...
1050
Retirando...
950
Interes...
1045.0
Retirando todo...
0
1045.0
import random

class CuentaInversion(Cuenta):

    def __init__(self, nombre, deposito):
        super().__init__(nombre, deposito)

    def retira_todo(self, riesgo):
       cantidad = self.deposito
       self.deposito = 0
       cantidad = random.gauss(1, riesgo)*cantidad
       return cantidad
cuenta = CuentaInversion("Pepito", 1000)

print(cuenta.nombre)
print(cuenta.deposito)

print("Depositando...")
cuenta.deposita(50)
print(cuenta.deposito)

print("Retirando...")
cuenta.retira(100)
print(cuenta.deposito)

print("Interes...")
cuenta.interes()
print(cuenta.deposito)

print("Retirando todo...")
total = cuenta.retira_todo(riesgo=0.5)
print(cuenta.deposito)
print(total)
Pepito
1000
Depositando...
1050
Retirando...
950
Interes...
1045.0
Retirando todo...
0
1172.4324788606912

Métodos y atributos de clase#

En las definiciones de clase que hemos visto, estamos asumiendo que los métodos definidos van a operar un instancias de dicha clase, por ello incluyen siempre el parámetro self. Sin embargo, una clase es un objeto más y por lo tanto tiene un estado que puede ser manipulado. Por ejemplo, podríamos llevar un contandor de cuántas instancias de la clase se han creado. Mira el siguiente ejemplo

class Account:
    num_accounts = 0 # Esto es un atributo de clase

    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
        Account.num_accounts += 1

    def __repr__(self):
        return f'{type(self).__name__}({self.owner!r}, {self.balance!r})'

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.deposit(-amount)

El atributo num_accounts es un ejemplo de lo que se denomina un atributo de clase, que son definidos fuera del método init. Para modificarlo o acceder a ellos se aplican directamente a la clase

account_pepito = Account("Pepito", 1000)
Account.num_accounts
1

Aunque tambíen se puede acceder a ellos a través de la instancias de la clase

account_pepito.num_accounts
1
account_alicia = Account("Alicia", 2000)
account_pepito.num_accounts
2

Al intentar acceder a un atributo de uns instancia, Python busca primero en los atributos de instancias y si no encuentra nada accede a los atributos de clase.

También se pueden definir métodos de clase, que es un método que se aplica a la clase misma, no a las instancias. Un uso común de los mismos es para definir constructores de la clase alternativos. Por ejemplo, supongamos que queremos añadir un método para crear instancias de la clase Account a partir de la información de un archivo xml con la siguiente escrutura

<account>
    <owner>Javi</owner>
    <amount>1000</amount>
</account>

Podríamos utilizar el decorador @classmethod para escribir un método de clase. El primer argumento de un método de clase es la clase misma, y es bastante común nombrarlo como cls.

from inspect import classify_class_attrs
class Account:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance

    @classmethod
    def from_xml(cls, data):
        from xml.etree.ElementTree import XML
        doc = XML(data)
        return cls(doc.findtext("owner"), float(doc.findtext("amount")))
data = """
<account>
    <owner>Javi</owner>
    <amount>1000</amount>
</account>
"""

javi_account = Account.from_xml(data)

Finalmente, podríamos usar los atributos y métodos de clase para formar una clase Date con varios constructores y la que manejemos el formateo de la fecha con un atributo de clase (favoreciendo la herencia de clases para crear clases en función del formato)

import time

class Date:
    datefmt = '{year}-{month:02d}-{day:02d}'
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day

    def __str__(self):
        return self.datefmt.format(
            year=self.year,
            month=self.month,
            day=self.day
        )

    @classmethod
    def from_timestamp(cls, ts):
        tm = time.localtime(ts)
        return cls(tm.tm_year, tm.tm_mon, tm.tm_mday)

    @classmethod
    def today(cls):
        return cls.from_timestamp(time.time())

class MDYDate(Date):
    datefmt = "{month}/{day}/{year}"

class YMDDate(Date):
    datefmt = "{year}/{month}/{day}"
a = Date.today()
b = MDYDate(2023, 2, 7)
c = YMDDate(2023, 2, 7)

print(a)
print(b)
print(c)
2023-02-07
2/7/2023
2023/2/7