Programación orientada a objetos en Python 3 (la Princesa Peach ha sido raptada, otra vez)

Lino Alberto Urdaneta Fernández
qu4nt
Published in
15 min readJul 8, 2019
Imagen cortesía de Alexas_Fotos (en Pixabay)

Vamos a dar un paseo por el mundo de los Super Mario Bros, pero lo haremos al estilo de Python 3. Primero, vamos a retroceder un poco en el tiempo para recordar las primeras aventuras de Mario y Luigi en 8 bits. Estos juegos consistían en moverse de izquierda a derecha, sorteando obstáculos, mientras vencíamos o esquivábamos enemigos hasta llegar al final de cada nivel. En Super Mario Bros 3 teníamos también una serie de “trajes” que nos daban poderes especiales (¿recuerdas el de mapache o el de rana?). Se trataba de juegos con mecánicas muy sencillas que resultaban muy divertidos. Vamos a modelar algunos aspectos de estos juegos con Python para explicar las diferencias entre la Programación Orientada a Objetos (llamada OOP por sus siglas en inglés) y la programación procedimental u orientada a procedimientos.

Object-Oriented Programming (OOP) o Programación Orientada a Objetos (POO) es un paradigma de programación basado en la concepción de objetos que pueden almacenar información o procesarla.

Super Mario Bros estilo procedimental

¿Podemos imaginar una receta para crear juegos de Super Mario Bros, o juegos de plataformas en general? Ciertamente. Lo que tendríamos que hacer es, primero que todo, identificar los ingredientes y explicar paso a paso qué hacer con ellos. Al igual que cuando le damos a alguien una receta de cocina. ¿Cómo sería una receta de este tipo? Aquí va una versión muy simplificada.

Ingredientes:
- 1 Mario
- 1 Luigi
- 1 Princesa Peach
- 1 Bowser
- 100 Koopa Troopa
- 200 Goomba
- 50 Boo

Procedimiento

Haga que Bowser secuestre a la Princesa Peach antes de que empiece el juego. Ahora tome a Mario (o a Luigi si es el jugador # 2) y recorra cada nivel de izquierda a derecha. En el nivel ponga distintos tipos de obstáculos (al gusto). También debe colocar enemigos de manera más o menos frecuente. Mario y Luigi pueden saltar, comer hongos para crecer, romper bloques que estén justo encima de ellos con el puño, lanzar fuego si consiguen la flor, volar y dar golpes con la cola si tienen el traje de mapache, etc. Repita el proceso con varios niveles y sub-niveles más hasta que Mario o Luigi rescaten a la Princesa.

No vamos a ver cómo hacer todo esto en Python, sino que vamos a utilizar algunas partes de esta receta para modelar algunas funciones. Empecemos introduciendo a los hermanos más famosos de los videojuegos:

player1 = 'Mario'
player2 = 'Luigi'

Si nos quedamos aquí, terminaremos con el juego más aburrido de la historia. Vamos a introducir algunos enemigos también:

enemy1 = 'Koopa Troopa'
enemy2 = 'Goomba'

Aun así, sigue siendo aburrido, porque nadie hace nada. Lo que necesitamos para que esto empiece a ponerse emocionante son funciones que hagan algo interesante sobre los elementos anteriores. Algo como lo siguiente.

def jump(name):
print(name, "jumps!")
¡Bowser también salta!

¡Ya podemos hacer que Mario y Luigi salten! Bueno, también un Koopa o Bowser podrían hacerlo si lo pasamos como argumento de la función. En Python una función se define utilizando la palabra def seguida del nombre de la función, y entre este nombre y los dos puntos van unos paréntesis que incluyen los parámetros de la función. Después de los dos puntos, de forma indentada, ponemos el cuerpo de la función que es donde ocurren las cosas interesantes. Por cierto, vamos a hacer que Luigi salte:

>>> jump(player2)
Luigi jumps!

Podemos crear una función para el movimiento lateral también.

def movement(name, direction):
print(name, "moves", direction)

Así se mueve Mario hacia la izquierda:

>>> movement(player1, 'left')
Mario moves left

Incluso podemos combinar las dos funciones que tenemos para que Mario se mueva y salte en un solo movimiento.

def run_and_jump(name, direction):
movement(name, direction)
print("and")
jump(name)

Vamos a probarlo con Mario (player1):

>>> run_and_jump(player1, 'right')
Mario moves right
and
Mario jumps!

Normalmente estas funciones van a ser utilizadas miles de veces en un juego. Una función es un fragmento de código reutilizable que evita que tengamos que escribir funcionalidades repetidas para cada punto distinto del código. Cada vez que creas que tienes que repetir una funcionalidad, te recomiendo que diseñes una función para eso. Si escribes código, y ves que hay patrones que se repiten, aprovéchalos y conviértelos en una o varias funciones.

Una función es un fragmento de código reutilizable que evita que tengamos que escribir funcionalidades repetidas para cada punto distinto del código.

Podríamos seguir con este procedimiento y completar la “receta” hasta obtener un juego completo. Sin embargo, este artículo tiene como protagonista otro enfoque.

Super Mario Bros estilo OOPs

Uppps, franquicia equivocada. Bueno, vamos a ver a Sonic correr contra Sonic.

Modelar el juego utilizando una aproximación de receta es válido; pero en este caso no parece totalmente natural. Para empezar, las funciones no restringen quien puede saltar: así que Bowser o un Koopa podrían hacerlo perfectamente. No suena mal, pero piensa en que nada impide que un Boo pueda ponerse el traje de mapache. Por supuesto, para hacer algo como eso tendríamos que pasar al Boo como un argumento de la función del traje, y si no lo hacemos durante el código, el pobre Boo tendrá que conformarse con hacer lo que quiera que los boos hacen comúnmente. Piensa también que nada evitaría que una planta carnívora sea argumento de la función movement(), lo que sería una pesadilla para nuestros héroes. O que Bowser utilizara la estrella de invulnerabilidad.

Otro problema con nuestra receta es que los “ingredientes” parecen todos iguales: son nombres almacenados en una variable, nada más. Es como si realmente no tuvieran identidad propia.

Los “ingredientes” y nuestros procedimientos están muy separados los unos de los otros en las definiciones y solo se encuentran luego, cuando empezamos a pasar argumentos a las funciones. Esto de por sí no está mal, pero instintivamente pensamos que a veces hay elementos que se definen más por sus funcionalidades que por otra cosa. Otra forma de decirlo es que hay atributos y funciones que parecen estar muy vinculados entre ellos, y que esta relación no es del todo transparente en el código de nuestro juego.

Entonces, en lugar de pensar en el formato de una receta, vamos a pensar nuestro juego como un conjunto de objetos que pueden hacer cosas y así interactuar entre ellos. Es lo que se denomina Programación Orientada a Objetos.

Vamos a suponer que Mario y Luigi son entidades que pueden moverse y saltar. Para ello, vamos a hacer un esquema de lo que tienen en común estas entidades. Lo haremos con la palabra reservada class.

class Plumber():
"""
Creates a plumber
"""
def __init__(self, name):
self.name = name

Mario y Luigi no son oficialmente plomeros (fontaneros), pero así los he imaginado siempre.

Con class creamos una clase, que funciona como un blueprint para crear objetos. Con esto, ya podemos crear un Mario o un Luigi de la siguiente manera:

player1 = Plumber('Mario')
player2 = Plumber('Luigi')

Fíjate que con una sola clase hemos creado a los dos hermanos. Los nuevos objetos tipo Plumber se almacenan en variables (player1 y player2). Pero vamos a volver un poco atrás para explicar qué hay dentro de la clase Plumber.

En primer lugar, después de la palabra class aparece el nombre de la clase; es común que el nombre empiece con mayúsculas para indicar que se trata de una clase. Al nombre le siguen un par de paréntesis vacíos (explicaré esto luego). Y luego dos puntos para indicar que viene un bloque indentado (el cuerpo de la clase).

Justo después de esto vamos a ver una breve descripción de la clase que estamos definiendo: es lo que llamamos un docstring y sirve para documentar el código (puedes encotrar más información acá). Y después de esto, verás que hemos introducido una función dentro de la clase. A estas funciones “internas” no las llamamos así, sino que las denominamos métodos (method). Este método en particular se denomina constructor (__init__) y es el que indica los parámetros de la clase (en este caso hay dos: self y name). Usando este método __init__ le estamos diciendo a la clase que le diremos el nombre del personaje, y que ese nombre se almacenará en la variable name. Si no has utilizado nunca una clase te estarás preguntando qué significa ese self. Esta es también una palabra especial que apunta al propio objeto una vez creado. Sé que puede sonar raro, pero es algo sencillo. Veamos qué pasa cuando creamos a Mario:

  • Escribimos una variable seguida del operador de asignación ( = ), y a continuación el nombre de la clase.
  • A la clase le pasamos el nombre del personaje entre comillas. Este nombre se almacena en la variable name del método __init__ .
  • Ese nombre se guarda ahora en la variable self.name . ¿Qué hace esto? Básicamente dice que si creamos un objeto player1 , el nombre de Mario se asociará a player1.name. Esto lo conseguimos con el self, que toma como referencia, en cada caso, al objeto creado.

Nuestros players hasta ahora no hacen nada interesante, sino darnos su nombre:

>>> player1.name
Mario
>>> player2.name
Luigi

Podemos incorporar las funciones de salto y de movimiento metiendo dentro de la clase las funciones que ya hicimos arriba. Para que funcionen hace falta solamente utilizar self para que las funciones sepan que deben trabajar para objeto creado:

class Plumber():
"""
Creates a plumber
"""
def __init__(self, name):
self.name = name
def jump(self):
print(self.name, "jumps!")
def movement(self, direction):
print(self.name, "moves", direction)
El self y su brujería vudú.

Observa que de las funciones (métodos los llamaremos ahora) han desaparecido los parámetros name, que ya no nos hacen falta porque el nombre del objeto que se creará estará guardado en self.name. El self (¡otra vez el self!) será el responsable de que se pueda ejecutar cada método para cada objeto. Observa los siguientes ejemplos (puedes probarlos desde el intérprete de Python).

>>> player1 = Plumber('Mario')
>>> player2 = Plumber('Luigi')
>>> player1.name
Mario
>>> player1.jump()
Mario jumps!
>>> player2.movement('left')
Luigi moves left

El self es una palabra especial que apunta al propio objeto una vez creado.

Para usar el método jump() no hace falta pasar argumento alguno (en el código de la clase solo tiene self, que es siempre un parámetro interno de la clase); en el método movement() sí hay que introducir un argumento porque lo requiere la definición de dicho método (el parámetro direction). Intenta crear a partir de estos elementos un nuevo personaje (como la Princesa Peach de Super Mario Bros 2) que se mueva y salte. También puedes probar incluyendo un método que indique que el personaje se mueve y salta al mismo tiempo.

Desde el punto de vista de los resultados, parece que con crear una clase no hemos ganado nada realmente. Mario y Luigi pueden saltar y moverse, pero también lo podían hacer antes, solamente usando funciones, ¿no? Ciertamente el programa hace lo mismo. Entonces, ¿para qué complicarnos la vida con las clases, el self, el método de inicialización (constructor) y todo eso? En el ejemplo de los hermanos de Nintendo, el uso de los métodos propios de la clase permite mantener organizada cada funcionalidad al asignarlas solamente a un tipo de objeto particular. Así podemos mantener una relación constante y lógica entre los distintos objetos y agrupaciones de funcionalidades relacionadas con cada uno. Se trata, en buena medida, de un paradigma de programación orientado a la organización del código.

Entonces, en lugar de pensar en el formato de una receta, vamos a pensar nuestro juego como un conjunto de objetos que pueden hacer cosas y así interactuar entre ellos. Es lo que se denomina Programación Orientada a Objetos.

Jugador 1: Seleccione un personaje (o una clase)

Así te sientes ahora que sabes crear clases, ¿no?

Atributos de la clase y atributos del objeto

Veamos algunas otras características interesantes de la Programación Orientada a Objetos. En el código anterior, el atributo name adquiere valores distintos para cada objeto (Mario en un objeto, Luigi en otro). Se trata de atributos del objeto. También podemos definir atributos para toda la clase. ¿Cómo? Simplemente asignando dentro de la clase una variable, pero sin el self (recuerda que el self hace que algo sea único del objeto). Vamos a incluir un atributo de clase llamado character_type que indique que se trata de fontaneros:

class Plumber():
"""
Creates a plumber
"""
character_type = 'plumber' # <- Atención con esto def __init__(self, name):
self.name = name
def jump(self):
print(self.name, "jumps!")
def movement(self, direction):
print(self.name, "moves", direction)

Ahora podemos acceder a información del objeto, o de la clase en general:

>>> player2.name
Luigi
>>> player2.character_type
plumber

No importa cuantos plomeros creemos, siempre serán plomeros, y no koopas 😍😍😍. Por cuestiones de estilo de programación, tus atributos de clase deberíar ir justo después del docstring y antes de los métodos de la clase.

Clases que dan lugar a otras clases

Wario aprueba este artículo

Una de las características más interesantes de la Programación Orientada a Objetos es que podemos utilizar clases más “generales” (o superclases) para crear, a partir de ellas, otras clases. Suena complicado, pero no lo es. Se trata de diseñar moldes muy básicos para luego crear, a partir de estos, otros moldes un poco más detallados. Vamos a crear, a modo de ejemplo, a Wario, ese némesis de Mario. Para ello vamos a crear una clase nueva. Pero, ¿por qué no utilizar la que ya hemos creado? Wario igualmente puede moverse y saltar. Pero, para ser exactos, no se trata de un plomero, o no queremos tratarlo como tal. Además, es malvado, y no queremos empaquetarlo con nuestros héroes.

class Villain():
"""
Creates a villain
"""
character_type = 'villain' def __init__(self, name):
self.name = name
def jump(self):
print(self.name, "jumps!")
def movement(self, direction):
print(self.name, "moves", direction)

Para distinguirlo de Mario y Luigi, a esta clase le hemos asignado el atributo de villano. Podemos utilizar esta nueva clase para crear objetos como los hammer brothers o algún otro personaje malo que salte y se mueva.

Si lo piensas bien, hemos repetido mucho código entre las clases Plumber y Villain. Y es que tienen muchas cosas en común. Para evitar estas repeticiones, vamos a crear una sola clase que agrupe las similitudes, y a partir de ella diseñaremos dos nuevas clases que enfaticen más las diferencias.

Primero, vamos a establecer nuestra clase base o “madre”. No será muy distinta de lo que ya hemos hecho.

class Character():
"""
Creates a game character
"""
def __init__(self, name):
self.name = name
def jump(self):
print(self.name, "jumps!")
def movement(self, direction):
print(self.name, "moves", direction)

Fíjate que esta clase es casi idéntica a las anteriores. Solo hemos eliminado el atributo de la clase character_type.

En base a esta clase, vamos ahora a redefinir las clases Plumber y Villain para que hereden los atributos y métodos comunes. Pero también para que se destaquen las diferencias. Primero vayamos con la clase Plumber:

class Plumber(Character): # <- Fíjate en lo que está en paréntesis
"""
Creates a plumber
"""

character_type = 'plumber'

Ahora con Villain:

class Villain(Character): # <- Fíjate en lo que está en paréntesis
"""
Creates a villain
"""

character_type = 'villain'

Las nuevas clases Villain y Plumber heredan la estructura básica de Character, pero agregan un atributo único para los plomeros y los villanos (el character_type). ¿Cómo le decimos a Python que una clase es “hija” de otra? Muy sencillo. Solo tenemos que poner el nombre de la clase base entre paréntesis justo al lado del nombre de la nueva clase (en la línea en que definimos el inicio de la clase).

Estas dos clases funcionan exactamente igual que antes, pero con un código más ordenado.

>>> player2 = Plumber('Luigi')>>> player2.name
Luigi
>>> player2.character_type
plumber
>>> player3 = Villain('Wario')
>>> player3.character_type
villain

Y no sólo tenemos código más ordenado, sino que ahora crear nuevos tipos de personaje es mucho más sencillo. ¿Sabrías cómo crear a la Princesa Peach? Así:

class Princess(Character):
"""
Creates a princess
"""

character_type = 'princess'

Lo mejor es que la Princesa ya está lista para moverse y saltar, ya que hereda estas funciones de la clase base.

>>> player4 = Princess('Peach')
>>> player4.jump()
Peach jumps!
¡Es tan sencillo que hasta los personajes de Super Mario Bros saben hacerlo!

Podemos preguntarle a Python si un objeto pertenece a una clase determinada con la función isinstance():

>>> print(isinstance(player4, Character))
True

O, si queremos ver los atributos y funciones de un objeto, podemos utilizar la función dir() que viene incluida en Python (no muestro acá todos los resultados):

>>> dir(player4)
...
'name'
'character_type',
'jump',
'movement'

Ya podemos incluir en nuestro juego a montones de personajes nuevos. ¿Crees que podemos derivarlos todos de nuestra clase Character? MMMmmm, la verdad es que no, porque hay enemigos que no se pueden mover o saltar. Para ello deberíamos crear una nueva clase que no implique que el personaje pueda realizar estas acciones. Esta clase no sería heredera de Character. Pero tenemos otra opción. Podemos crear una clase que herede todo lo de Character, pero que sobrescriba (override) lo de saltar y moverse para desactivar estas funciones. Lo podemos hacer de la siguiente manera.

class CarnivorousPlant(Character):
"""
Creates a princess
"""

character_type = 'carnivorous plant'
def jump(self): # <- Sobrescribimos esta función...
print("I can't jump you moron!")
def movement(self, direction): # <- ... y también esta
print("Is this some kind of joke?")

Aunque la clase CarnivorousPlant hereda los métodos saltar y moverse, podemos sobrescribirlos sin ningún problema. De esta manera, una clase puede heredar lo que más nos convenga y no heredar lo que pueda causarnos algún problema.

>>> plant = CarnivorousPlant('Generic Plant')
>>> plant.movement('left')
Is this some kind of joke?

Y el juego solo empieza…

Solo hemos tocado los principios elementales de la Programación Orientada a Objetos. El camino para dominarla es un poco más largo. Estamos en el nivel 1–4 apenas del juego. Pero tampoco subestimes lo que hemos revisado: lo poco que has visto aquí se puede convertir en algo muy poderoso con un poco de ingenio. Para ir terminando, vamos a ver de manera muy superficial los cuatro pilares de la OOP.

Herencia

Ya viste la herencia en acción cuando creamos la clase base Character. La posibilidad de crear clases a partir de otras clases simplifica mucho el trabajo en proyectos grandes si se hace con inteligencia. Es algo que verás muy frecuentemente si trabajas con frameworks creados en Python. La herencia hace posible crear fragmentos de código muy reutilizables.

Abstracción

Dentro de la definición de una clase puede haber algunos pasos intermedios, o resultados provisionales que no son del todo relevantes para el usuario de dicha clase. La abstracción permite mostrar, por ejemplo, solo aquellos atributos o métodos relevantes al usuario, dejando “ocultos” otros aspectos menos interesantes para el uso de la clase. Por ejemplo, cuando definimos a la planta carnívora sobreescribimos un par de métodos provenientes de la clase Character. Si bien los resultados de estas modificaciones son importantes para la definición de nuestro personaje, al usuario de la clase quizás no le importe mucho si se hizo como un overriding de una clase más genérica (lo que de hecho hicimos), o si en cada clase derivada se definen de forma particular estos métodos.

Encapsulación

Vimos arriba que una de las ventajas de la OOP es que reúne en un objeto atributos y funcionalidades relacionados entre sí; a esto se le conoce con el nombre de encapsulación. Esta encapsulación es una forma “natural” de definir objetos en muchos casos, y es una de las motivaciones principales por las cuales se desarrolló este paradigma de programación. En la primera parte de este artículo definimos de forma procedimental algunas funciones para Mario y Luigi, que si bien funcionan bien, se pueden llegar a sentir “desconectadas” entre ellas, ya que nada nos dice que una función de esas esté relacionada con otra. Te habrás preguntado desde hace rato dónde quedará en jump() todo lo que tiene que ver con la imagen de Mario en medio del salto, su cambio de posición en pantalla, los sonidos que hace, la interacción con los otros personajes y elementos del juego. Aún no lo hemos incluido, pero lo cierto es que una vez que lo hayamos hecho solamente tendrás que escribir player1.jump() porque todos esos detalles que acompañan la acción de saltar quedarán encapsulados dentro del método run(). Ese es el poder del encapsulamiento (algo así como todos para uno y uno para todos).

Polimorfismo

A veces nos interesa que las funciones que tenemos dentro de la clase se adapten al objeto con el que trabajamos o con los argumentos que pasamos a los métodos. A esto lo denominamos polimorfismo. Cuando sobrescribimos los métodos jump() y movement() para las plantas carnívoras usamos el polimorfismo: cambiamos la estructura de las funciones para adaptarlas a las particularidades del objeto. En Python también vemos el polimorfismo en acción cuando usamos la función len() sobre una lista o una cadena; Python reconoce el tipo de objeto y sabe que tiene que contar caracteres o elementos respectivamente. En OOP el polimorfismo permite que nuestros objetos de adapten a las circunstancias.

Camino al mundo 8 (final stage)

Hemos dado los primeros pasos por el mundo de la Programación Orientada a Objetos en Python. El camino es largo, pero todo viaje comienza con un simple paso. Puedes hacer cosas muy interesantes en este lenguaje de programación sin tener que definir tus propias clases, utilizando solamente funciones. Pero te invito a que cada vez que te enfrentes a un problema, pienses si una solución basada en objetos no sería un mejor camino. La decisión es tuya. A fin de cuentas, Super Mario Bros nos ha enseñado que siempre hay más de un camino para llegar al último mundo y terminar el juego, ¿no?

ACTUALIZACIÓN: Para que te decidas a emprender esta ruta, te dejamos un libro que nos parece muy útil tanto para los primeros pasos como para un desarrollo más profundo en la Programación Orientada a Objetos (POO). Steven F. Lott y Dusty Phillips, en la 4ta edición de Python Object-Oriented Programming: Build robust and maintainable object-oriented Python applications and libraries, abordan en este libro los conceptos más importantes de POO que abordamos en este artículo, explicando cómo funcionan junto con las clases y estructuras de datos de Python para facilitar un buen diseño de aplicaciones. Nos resultó muy atractivo que este libro aborda dos sistemas de pruebas automatizadas muy potentes: unittest y pytest que resulta especialmente útiles en el desarrollo de aplicaciones robustas.

Toma la pastilla azul de las funciones y vuelve a tu vida de siempre (la que siempre has conocido), o toma la roja de los objetos y entederás cómo funciona realmente la realidad (bueno, sólo Python). La decisión es tuya…

--

--

Lino Alberto Urdaneta Fernández
qu4nt
Writer for

Lingüista computacional. No sé qué me pasó, de niño adoraba las computadoras y luego me dio por ser ¿humanista? Ahora hago un poco de ambas cosas.