Python OOP: la programación orientada a objetos en Python

Desde la llegada de la versión 3, Python se basa totalmente en la programación orientada a objetos (OOP) y sigue la filosofía de diseño “everything is an object”, es decir, que todo son objetos.

A diferencia de lo que ocurre con Java, C++ y Python 2.x, a partir de esta versión no hay diferencia entre los tipos primitivos y los objetos. En Python, las cifras, los strings y las listas, así como las funciones y las clases, son objetos.

En comparación con otros lenguajes de programación, la OOP basada en clases de Python se caracteriza por una elevada flexibilidad y pocas limitaciones. En este sentido, es el opuesto extremo a Java, cuyo sistema OOP se considera extremadamente rígido. Te explicamos de manera práctica cómo funciona en Python la programación orientada a objetos.

¿Para qué sirve la programación orientada a objetos en Python?

La programación orientada a objetos es una forma de programación imperativa. Los objetos combinan datos y funcionalidad. Un objeto encapsula su estado interno, y se puede acceder a él mediante una interfaz pública llamada interfaz del objeto. La interfaz del objeto viene definida por sus métodos. Los objetos interactúan unos con otros mediante mensajes que se trasmiten llamando a los métodos.

Consejo

Para entender mejor el contexto de la OOP de Python, consulta nuestros artículos “Qué es OOP”, “Paradigmas de programación” y “Tutorial de Python”.

Encapsular objetos OOP con Python

Veamos con un ejemplo cómo puedes usar la OOP para encapsular objetos en Python. Supón que estás escribiendo código para una cocina, un bar o un laboratorio. Necesitarás modelar recipientes como botellas, vasos, tazas, etc.; todos objetos que tienen un volumen y se pueden llenar. Una categoría de objetos es una “clase”.

Los objetos que representan esos recipientes tienen un estado interno que puede cambiar. Estos contenedores pueden rellenarse, vaciarse y demás. Si tienen tapa, puedes abrirlos y cerrarlos. Sin embargo, lógicamente no es posible modificar el volumen de un recipiente a posteriori. Obviamente hay que hacerse múltiples preguntas sobre su estado, por ejemplo:

  • “¿El vaso está lleno?”
  • “¿Qué volumen tiene la botella?”
  • “¿El recipiente tiene tapa?”

Además, tiene sentido que haya una relación entre los objetos. Por ejemplo, debería poder transferirse el contenido de un vaso a una botella. A continuación, verás cómo cambia el estado interno de un objeto con la programación orientada a objetos en Python. Los cambios o preguntas que se hacen sobre el estado mostrado se llevan a cabo con llamadas a métodos:

cup = Container(400)
assert cup.volume() == 400
assert not cup.is_full()
# add some water to the cup
cup.add('Water', 250)
assert cup.volume_filled() == 250
# add more water, filling the cup
cup.add('Water', 150)
assert cup.is_full()
Python

Definir tipos con OOP en Python

Los tipos de datos son un concepto básico de la programación. Los distintos tipos de datos pueden usarse de forma y manera distinta: las cifras se procesan mediante operaciones aritméticas y las cadenas de caracteres (o strings) pueden examinarse.

# addition works for two numbers
39 + 3
# we can search for a letter inside a string
'y' in 'Python'
Python

Si intentas sumar una cifra y un string o buscar dentro de un número, te dará un error de tipo:

# addition doesn't work for a number and a string
42 + 'a'
# cannot search for a letter inside a number
'y' in 42
Python

Los tipos integrados en Python son abstractos. Una cifra puede representar de todo: distancia, tiempo, dinero. El significado del valor solo se indica con el nombre de la variable:

# are we talking about distance, time?
x = 51
Python

¿Pero y si quieres modelar conceptos especializados? Eso también puede hacerse con la programación orientada a objetos y Python. Los objetos son estructuras de datos con tipos identificables que pueden mostrarse con la función incorporada type():

# class 'str'
type('Python')
# class 'tuple'
type(('Walter', 'White'))
Python

Crear abstracciones con Python OOP

En la programación se usan las abstracciones para ocultar las complejidades. Estas permiten a los programadores operar a un nivel más elevado. Por ejemplo: es lo mismo preguntar “¿El vaso está lleno?” que “¿El volumen del contenido del vaso es igual al volumen del vaso?”. La primera versión más abstracta es más corta y concisa y, por tanto, preferible. Las abstracciones permiten crear y comprender sistemas más complejos:

# instantiate an empty glass
glass = Container(250)
# add water to the glass
glass.add('Water', 250)
# is the glass full?
assert glass.is_full()
# a longer way to ask the same question
assert glass.volume_filled() == glass.volume()
Python

En Python, la OOP permite aplicar conceptos abstractos a ideas nuevas. He aquí un ejemplo con el operador de suma de Python. El signo más suele conectar dos cifras, pero también puede usarse para sumar el contenido de dos (o más) listas:

assert 42 + 9 == 51
assert ['Jack', 'John'] + ['Jim'] == ['Jack', 'John', 'Jim']
Python

Puedes aplicar fácilmente el concepto de la suma al ejemplo. Definir un operador de suma para los recipientes permite escribir un código que se lee prácticamente como una lengua normal. Más abajo tienes la implementación, pero primero veamos un ejemplo de cómo se usa:

# pitcher with 1000 ml capacity
pitcher = Container(1000)
# glass with 250 ml capacity
glass = Container(250)
# fill glass with water
glass.fill('Water')
# transfer the content from the glass to the pitcher
pitcher += glass
# pitcher now contains water from glass
assert pitcher.volume_filled() == 250
# glass is empty
assert glass.is_empty()
Python

¿Cómo funciona la programación orientada a objetos en Python?

Los objetos combinan datos y funciones; ambos llamados atributos. A diferencia de Java, PHP y C++, la OOP de Python no dispone de palabras clave como private o protected para restringir el acceso a los atributos. En vez de eso, aplica la siguiente convención: los atributos que empiezan con un guion bajo se consideran no públicos. Pueden ser atributos de datos o métodos que sigan el esquema _internal_attr o _internal_method().

En Python, los métodos se definen con la variable self como primer parámetro. Para acceder a un atributo de objeto desde dentro del objeto hay que hacer referencia a self. En Python self funciona como marcador de posición de una instancia concreta, por lo que es parecida a la palabra clave this en Java, PHP, JavaScript y C++.

Además de la convención que mencionamos arriba, también se crea un esquema sencillo para el encapsulado: acceder a un atributo interno con la referencia self._internal, ya que esta se encuentra dentro del objeto. Acceder desde fuera con algo tipo obj._internal va contra el encapsulado, por lo que conviene evitarlo:

class ExampleObject:
    def public_method(self):
        self._internal = 'changed from inside method'
# instantiate object
obj = ExampleObject()
# this is fine
obj.public_method()
assert obj._internal == 'changed from inside method'
# works, but not a good idea
obj._internal = 'changed from outside'
Python

Clases

Una clase es como una plantilla para los objetos. Se dice que un objeto se instancia a partir de las clases, o sea, que se crea según la plantilla. Según la convención, los nombres de las clases definidos por el usuario empiezan con mayúscula.

A diferencia de lo que sucede con Java, C++, PHP y JavaScript, en Python OOP no existe la palabra clave new. En su lugar, el nombre de la clase se invoca como una función y sirve de constructor que devuelve una nueva instancia. Implícitamente, el constructor invoca la función de inicialización __init__(), que inicializa los datos del objeto.

La siguiente tabla recopila los patrones explicados hasta el momento con ejemplos de código. Ahora verás cómo se modela el concepto de contenedor con una clase llamada Container y cómo se definen métodos para interacciones importantes:

Método Explicación
__init__ Inicializa el contenedor nuevo con los valores iniciale.
__repr__ Muestra el estado del contenedor en forma de texto.
volume Muestra la capacidad del contenedor.
volume_filled Expresa cómo de lleno está el contenedor.
volume_available Indica el espacio que queda libre en el contenedor.
is_empty Indica si el contenedor está vacío.
is_full Indica si el contenedor está lleno.
empty Vacía el recipiente y devuelve el contenido.
_add Método interno que añade una sustancia sin hacer comprobaciones.
add Método público que añade la cantidad establecida de una sustancia siempre y cuando haya espacio suficiente.
fill Rellena el espacio que queda libre en el contenedor con una sustancia.
pour_into Vierte todo el contenido del recipiente en otro contenedor.
__add__ Implementa el operador de la suma para recipientes; recurre al método pour_into.

Este es el código de la clase Container. Una vez que lo hayas ejecutado en tu REPL de Python localmente, podrás hacer pruebas con los otros ejemplos de código del artículo:

class Container:
    def __init__(self, volume):
        # volume in ml
        self._volume = volume
        # start out with empty container
        self._contents = {}
    
    def __repr__(self):
        """
        Textual representation of container
        """
        repr = f"{self._volume} ml Container with contents {self._contents}"
        return repr
    
    def volume(self):
        """
        Volume getter
        """
        return self._volume
    
    def is_empty(self):
        """
        Container is empty if it has no contents
        """
        return self._contents == {}
    
    def is_full(self):
        """
        Container is full if volume of contents equals capacity
        """
        return self.volume_filled() == self.volume()
    
    def volume_filled(self):
        """
        Calculate sum of volumes of contents
        """
        return sum(self._contents.values())
    
    def volume_available(self):
        """
        Calculate available volume
        """
        return self.volume() - self.volume_filled()
    
    def empty(self):
        """
        Empty the container, returning its contents
        """
        contents = self._contents.copy()
        self._contents.clear()
        return contents
    
    def _add(self, substance, volume):
        """
        Internal method to add a new substance / add more of an existing substance
        """
        # update volume of existing substance
        if substance in self._contents:
            self._contents[substance] += volume
        # or add new substance
        else:
            self._contents[substance] = volume
    
    def add(self, substance, volume):
        """
        Public method to add a substance, possibly returning left over
        """
        if self.is_full():
            raise Exception("Cannot add to full container")
        # we can fit all of the substance
        if self.volume_filled() + volume <= self.volume():
            self._add(substance, volume)
            return self
        # we can fit part of the substance, returning the left over
        else:
            leftover = volume - self.volume_available()
            self._add(substance, volume - leftover)
            return {substance: leftover}
    
    def fill(self, substance):
        """
        Fill the container with a substance
        """
        if self.is_full():
            raise Exception("Cannot fill full container")
        self._add(substance, self.volume_available())
        return self
    
    def pour_into(self, other_container):
        """
        Transfer contents of container to another container
        """
        if other_container.volume_available() < self.volume_filled():
            raise Exception("Not enough space")
        # get the contents by emptying container
        contents = self.empty()
        # add contents to other container
        for substance, volume in contents.items():
            other_container.add(substance, volume)
        return other_container
    
    def __add__(self, other_container):
        """
        Implement addition for containers:
        `container_a + container_b` <=> `container_b.pour_into(container_a)`
        """
        other_container.pour_into(self)
        return self
Python

Aquí tienes algunos ejemplos de la implementación de nuestro contenedor. Primero se instancia un vaso y se llena de agua. Como es de esperar, el vaso luego estará lleno:

glass = Container(300)
glass.fill('Water')
assert glass.is_full()
Python

En el siguiente paso, se vacía el vaso, que volverá a tener la cantidad de agua que tenía anteriormente. La implementación parece funcionar: el vaso está vacío.

contents = glass.empty()
assert contents == {'Water': 300}
assert glass.is_empty()
Python

Ahora un ejemplo algo más complejo. Para mezclar en una jarra vino y zumo de naranja, primero es necesario crear los recipientes y rellenarlos de los líquidos correspondientes:

pitcher = Container(1500)
bottle = Container(700)
carton = Container(500)
# fill ingredients
bottle.fill('Red wine')
carton.fill('Orange juice')
Python

Luego se usa el operador de suma y asignación += para verter el contenido de ambos recipientes en la jarra.

# pour ingredients into pitcher
pitcher += bottle
pitcher += carton
# check that everything worked
assert pitcher.volume_filled() == 1200
assert bottle.is_empty() and carton.is_empty()
Python

Esto ha sido posible porque la clase Container ha implementado el método __add__(). Entre bastidores, la asignación pitcher += bottle se convierte en pitcher = pitcher + bottle. Además, Python traduce pitcher + bottle al llamar el método pitcher. __add__(bottle). Nuestro método __add__() devuelve el recibidor, en este caso la jarra o pitcher, para que funcione la asignación.

Atributos estadísticos

Hasta ahora has visto cómo acceder a los atributos de los objetos: desde fuera a través de métodos públicos; dentro de los métodos con la referencia self. El estado interno de un objeto se alcanza mediante atributos de datos que pertenecen al objeto correspondiente. Pero los métodos de un objeto también están vinculados a una instancia concreta. Sin embargo, hay atributos que pertenecen a clases, lo cual tiene sentido ya que, en Python, las clases también son objetos.

Los atributos de clases también se denominan atributos “estáticos” porque ya existen antes de instanciar el objeto. Pueden ser tanto atributos de datos como métodos. Esto es útil para las constantes que son iguales para todas las instancias de una clase, al igual que para métodos que no operan con self. Las rutinas de conversión suelen implementarse como métodos estáticos.

A diferencia de otros lenguajes como Java y C++, Python no usa a la palabra clave static para distinguir entre atributos de objeto y atributos de clase. En su lugar, recurre a un decorador llamado @staticmethod. Más abajo verás un ejemplo de método estático para la clase Container. En él se implementa una rutina de conversión para pasar de onzas líquidas a mililitros:

# inside of class `Container`
    ...
    @staticmethod
    def floz_from_ml(ml):
        return ml * 0.0338140227
Python

A los atributos estáticos se accede como siempre haciendo referencia al atributo con la notación del punto siguiendo la estructura obj.attr. La única diferencia es que a la izquierda del punto va el nombre de la clase: ClassName.static_method(). Esta lógica es consistente ya que en la programación orientada a objetos en Python las clases también son objetos. De esta manera, para realizar la conversión de la clase Container, habrá que escribir:

floz = Container.floz_from_ml(1000)
assert floz == 33.8140227
Python

Interfaces

La interfaz es la agrupación de todos los métodos públicos de un objeto. Esta define y documenta el comportamiento de un objeto y sirve como API. Python, a diferencia de C++, no tiene planos distintos para la interfaz (archivos de encabezado) y la implementación; y a diferencia de Java y PHP, tampoco tiene una palabra clave explícita de interface. En estos lenguajes, las interfaces tienen firmas de métodos y sirven como descripción de sus funciones.

Dado que en Python la información sobre los métodos de los que dispone un objeto y la clase a partir de la cual se ha instanciado se determina dinámicamente en tiempo de ejecución, el lenguaje no requiere interfaces explícitas. En realidad, Python OOP aplica el principio del “Duck Typing”:

Cita

“If it walks like a duck and it quacks like a duck, then it must be a duck” — Fuente: https://docs.python.org/3/glossary.html#term-duck-typing Traducción: “Si camina como un pato y grazna como un pato, tendrá que ser un pato”. (Traducción de IONOS)

¿Qué significa Duck Typing? Básicamente, un objeto de Python puede usar una clase como un objeto de otra clase siempre y cuando contenga los métodos necesarios para ello. Para ilustrarlo, imagina un pato ficticio que hace los mismos sonidos que un pato, nada como un pato y los patos de verdad lo perciben como tal.

Transmisión por herencia

Al igual que en la mayoría de los lenguajes orientados a objetos, la OOP de Python también aplica el concepto de herencia en la que una clase se puede definir como especialización de una clase madre. Al continuar este proceso se va creando una jerarquía de clases en forma de árbol con la clase Object predefinida como raíz. Tanto Python como C++ (pero no Java ni PHP) permiten la herencia múltiple: una clase puede proceder de varias clases madre.

La herencia múltiple ofrece cierta flexibilidad. Por ejemplo, permite ejecutar los “mixins” conocidos de Ruby o los “traits” de PHP. Asimismo, la división de las funciones de Java en interfaces y clases abstractas puede aplicarse en Python con la herencia múltiple.

Volviendo al ejemplo de los recipientes, ahora verás cómo funciona la herencia múltiple en Python. Algunos contenedores pueden tener tapa; debes modificar la clase Container para ello. Tendrás que definir una nueva clase heredada de Container, SealableContainer, así como una nueva clase Sealable que contiene métodos para poner y quitar la tapa. La clase Sealable es un “mixin” porque solo sirve para dar a otra clase más implementaciones de métodos:

class Sealable:
    """
    Implementation needs to:
    - initialize `self._seal`
    """
    def is_sealed(self):
        return self._seal is not None
    
    def is_open(self):
        return not self.is_sealed()
    
    def is_closed(self):
        return not self.is_open()
    
    def open(self):
        """
        Opening removes and returns the seal
        """
        seal = self._seal
        self._seal = None
        return seal
    
    def seal_with(self, seal):
        """
        Closing attaches the seal and returns the Sealable
        """
        self._seal = seal
        return self
Python

SealableContainer proviene de la clase Container y del mixin Sealable. Ahora debes sobrescribir el método __init__() y definir dos parámetros nuevos que te permitan establecer el contenido y la tapa del SealableContainer al instanciarlo. Esto es necesario para crear recipientes cerrados con contenido. Dentro del método __init__(), usa super() para inicializar la clase madre:

class SealableContainer(Container, Sealable):
    """
    Start out with empty, open container
    """
    def __init__(self, volume, contents = {}, seal = None):
        # initialize `Container`
        super().__init__(volume)
        # initialize contents
        self._contents = contents
        # initialize `self._seal`
        self._seal = seal
    
    def __repr__(self):
        """
        Append 'open' / 'closed' to textual container representation
        """
        state = "Open" if self.is_open() else "Closed"
        repr = f"{state} {super().__repr__()}"
        return repr
    
    def empty(self):
        """
        Only open container can be emptied
        """
        if self.is_open():
            return super().empty()
        else:
            raise Exception("Cannot empty sealed container")
    
    def _add(self, substance, volume):
        """
        Only open container can have its contents modified
        """
        if self.is_open():
            super()._add(substance, volume)
        else:
            raise Exception("Cannot add to sealed container")
Python

Tal y como has hecho con el método __init__(), anula otros métodos que quieras para diferenciar los SealableContainer de los recipientes sin tapa. Sobrescribe __repr__() para que además se indique el estado abierto/cerrado. Asimismo, sobrescribe los métodos empty() y_add(), que solo tienen sentido con recipientes abiertos. De esta manera, obligas a abrir un contenedor cerrado antes de vaciarlo o rellenarlo. De nuevo, utiliza super() para acceder a las funciones de la clase madre.

Veamos un ejemplo. Imagina que quieres hacer un cuba libre. Para ello necesitas un vaso, una botella pequeña de cola y un vaso de chupito con 20 cl de ron:

glass = Container(330)
cola_bottle = SealableContainer(250, contents = {'Cola': 250}, seal = 'Bottlecap')
shot_glass = Container(40)
shot_glass.add('Rum', 20)
Python

Debes meter hielo en el vaso y añadir el ron. Como la botella de cola está cerrada, primero hay que abrirla y echar el contenido en el vaso:

glass.add('Ice', 50)
# add rum
glass += shot_glass
# open cola bottle
if cola_bottle.is_closed():
    cola_bottle.open()
# pour cola into glass
glass += cola_bottle
Python
¿Le ha resultado útil este artículo?
Page top