Clases y orientación a objetos

Clases y orientación a objetos

Este artículo pretende actuar como una pequeña guía para quellos que quieran introducirse en el paradigma de orientación a objetos en Python. Para aquellos que a grandes rasgos comprenden el concepto pero no logran avistar su utilidad real. Para aquellos, también, que necesiten comprender desde los cimientos del lenguaje a qué llamamos «clase», «instancia», «self», «__init__».

Comenzando por una clarificación de lo que entendemos por orientación a objetos, avanzamos paso a paso hasta la construcción de una clase, dirimiendo entretanto los cuestionamientos más comunes que surgen en la explicación.

El paradigma

¿Qué entendemos por orientación a objetos? Se trata de una forma de organizar el código. No es la única, claro. Algunas formas son más útiles que otras en ciertas ocasiones. Pero con esta pequeña definición pretendo mostrar que la orientación a objetos no es algo particular de ningún lenguaje. Más bien, algunos lenguajes proveen facilidades para incentivar el desarrollo del código siguiendo estos lineamientos. Python es uno de ellos, al igual que Java. La diferencia con éste es que en Python es perfectamente posible escribir código sin orientarlo a objetos. En cambio, Java requiere que cada programa contenga necesariamente una clase principal.

Hacer uso del paradigma de orientación a objetos es pensar y organizar el código en términos de clases y objetos. A los objetos también se los llama instancias. Pero ahora quiero entender a qué me refiero cuando hablo en estos términos.

Pensemos a las clases e instancias como planos y casas. Cuando diseñamos un plano, incluimos en él información tal como medida de la vivienda, números de habitaciones y baños y su correspondiente ubicación, pisos de este o aquel material, distribución de los espacios, etc. El plano no es en sí mismo ninguna casa; antes bien, describe cómo se verá ésta una vez construida. De modo que, si el plano contiene una puerta lateral, podemos asegurar que no nos encontraremos con una pared cuando nos dirijamos allí en la casa actual.

De la misma forma, una clase no constituye ningún objeto en particular. Más bien define las propiedades que tendrán las instancias de dicha clase una vez creadas; y aquí por propiedades entiendo variables y funciones. Las variables y funciones que están dentro de una clase llevan el nombre de atributos y métodos, respectivamente.

Ahora bien, intentemos llevar esto al diseño de aplicaciones reales. Para ello consideremos el programa de instalación de Python en la siguiente imagen.

Instalador de Python

Yo no he visto el código de fuente de este programa; no obstante, muy probablemente esté diseñado utilizando el paradigma de orientación a objetos. Podría asegurar que hay una clase que representa los botones, como los de Back, Next y Cancel. Llamémosla Button. Y como podemos observar, hay tres instancias de dicha clase ─los recién mencionados─. Pienso que Button debe tener, al menos, algún atributo que indique la posición del botón en la pantalla. Digamos que es Button.pos, una tupla que representa un par de coordenadas. Si las instancias de los tres botones son back_button, next_button y cancel_button, entonces sabemos que accediendo a back_button.pos, next_button.pos y cancel_button.pos obtendremos la posición de cada uno de ellos. También podríamos asignarle un nuevo valor al atributo para cambiar la posición de algún botón. Otro atributo común a todos los botones sería acaso Button.text.

Asimismo concibamos el método Button.focus(), una función que, al ser invocada, pone el foco sobre la instancia desde la cual ha sido llamada. Así, es evidente que next_button.focus() ha sido ejecutado al iniciarse la ventana, pues observamos en la imagen que el botón Next tiene el foco.

Siguiendo este razonamiento, avistamos otras potenciales clases como Checkbox e Imagebox. La primera tiene dos instancias: las opciones de instalación para el usuario actual o todos los usuarios. La segunda solo tiene una instancia: la imagen con el logo de Python. Así podríamos continuar hasta abarcar todos los elementos observables en la interfaz.

Entonces, ahora distinguimos en una aplicación real los conceptos de clase e instancia. Ciertamente no vemos ninguna de las clases en la imagen, sino las instancias. Pero a partir de éstas podemos hacernos una idea de cuáles son las clases a partir de las que fueron creadas.

Clases en Python

Veamos ahora cómo operamos con estos conceptos en el código. Supongamos que necesitamos desarrollar una aplicación para gestionar ─agregar, remover, editar─ la información de los estudiantes en un instituto. Como primer paso, para orientar nuestro programa a objetos, debemos crear la clase que represente al estudiante.

class Student:
    pass

Si bien nuestra clase está vacía ─no tiene atributos ni métodos─, ya podemos crear una instancia de ella; esto es, un estudiante.

student = Student()

(Recordemos que, según la guía de estilo del código Python, los nombres de instancias se escriben en letras minúsculas y cada palabra separada por guiones bajos. Por el contrario, las clases se nombran sin guiones bajos y poniendo en mayúscula la primera letra de cada palabra).

Ahora bien, nuestro estudiante debe tener un nombre. Llamémosle el atributo name. Como no es necesario declarar los atributos antes de usarlos ─al igual que cualquier otra variable en Python─ simplemente realizamos lo siguiente.

student.name = "Pablo"

Luego sencillamente accedemos a él de la misma forma. Por ejemplo, para imprimir su valor en pantalla:

print(student.name)

Avancemos un poco más. Agreguemos otro atributo; una lista que contenga los códigos de las asignaturas a las que se encuentra inscripto el estudiante.

student.subjects = [5, 14, 3]

Creemos ahora otro estudiante, stundent2, basándonos en la misma clase y asignándole los mismos atributos pero con distintos valores. El código completo iría de la siguiente forma.

class Student:
    pass

student = Student()
student.name = "Pablo"
student.subjects = [5, 14, 3]

student2 = Student()
student2.name = "Pedro"
student2.subjects = [1, 9]

Este abordaje tiene un problema. En primer lugar está la obvia repetición de código. Si quisiéramos añadir un atributo o bien modificar el nombre de uno existente, deberíamos hurgar en todo el código en busca de ocurrencias. Por otro lado, ¿qué pasaría si quisiéramos chequear que el atributo name sea una cadena y el atributo subjects una lista? Pues bien, simplemente, digamos, creemos una función que se encargue de ello.

class Student:
    pass

def initialize(student, name, subjects):
    if not isinstance(name, str):
        raise TypeError("name must be a string")
    elif not isinstance(subjects, list):
        raise TypeError("subjects must be a list")
    student.name = name
    student.subjects = subjects

student = Student()
initialize(student, "Pablo", [5, 14, 3])

student2 = Student()
initialize(student2, "Pedro", [1, 9])

initialize() se encarga, justamente, de darle un valor a cada uno de los atributos de la instancia, asegurándose previamente que cada uno de ellos sea del tipo correspondiente. Así, si alguno de ellos sufre algún tipo de modificación, únicamente nos dirigimos a nuestra función para efectuar los cambios.

Ahora bien, como la función initialize() ha sido diseñada para aplicarse a instancias de la clase Student, sería recomendable ubicarla dentro de ella como un método (nótese la indentación).

class Student:

    def initialize(student, name, subjects):
        if not isinstance(name, str):
            raise TypeError("name must be a string")
        elif not isinstance(subjects, list):
            raise TypeError("subjects must be a list")
        student.name = name
        student.subjects = subjects

Ahora, por cuanto initialize() es un método de la clase, las llamadas se ven de la siguiente manera.

student = Student()
Student.initialize(student, "Pablo", [5, 14, 3])

student2 = Student()
Student.initialize(student2, "Pedro", [1, 9])

Excelente, el método no entrará en conflicos con potenciales funciones de inicialización de otras clases (por ejemplo, una clase Teacher que describa a un docente). Pero, aun más interesante, ya que initialize() es un método de la clase Student cuyo primer argumento siempre es una instancia de ésta, Python nos permite llamarlo usando la nomenclatura instancia.metodo(...) como abreviación para Clase.metodo(instancia, ...). Por esta razón, el código anterior lo reescribimos como sigue.

student = Student()
student.initialize("Pablo", [5, 14, 3])

student2 = Student()
student2.initialize("Pedro", [1, 9])

Sucede que la creación de un método para inicializar atributos es una práctica tan común en la programación orientada a objetos, que el lenguaje reserva un nombre especial para esa función: __init__(). En Python, los objetos que comienzan y terminan con doble guión bajo siempre tienen un funcionamiento en particular y no son invocados directamente. Así, con el renombre pautado, el código de la clase pasaría a ser:

class Student:

    def __init__(student, name, subjects):
        if not isinstance(name, str):
            raise TypeError("name must be a string")
        elif not isinstance(subjects, list):
            raise TypeError("subjects must be a list")
        student.name = name
        student.subjects = subjects

No obstante, como dijimos, __init__() no está pensado para ser llamado directamente. Antes bien, Python lo invoca de forma automática cuando creamos una instancia de la clase. Y los argumentos que le pasamos a la clase son pasados al método de inicialización. De modo que nuestro código posterior se reduce a lo siguiente.

student = Student("Pablo", [5, 14, 3])
student2 = Student("Pedro", [1, 9])

Una clase puede tener tantos métodos como queramos ─sean estos «especiales» como __init__() o funciones convencionales─. Por ejemplo, sería util incluir un método que imprima en pantalla la información del estudiante (obviamos la definición del inicializador y la creación de las instancias para hacer énfasis en el código añadido).

class Student:

    # [...]
    
    def print_info(student):
        print("Nombre: {}.".format(student.name))
        print("Asignaturas: {}.".format(student.subjects))

# [...]

student.print_info()
student2.print_info()

¿Qué es «self»?

Pues bien. Habrás reparado en que todos los métodos dentro de una clase llevan por primer argumento una instancia de dicha clase, sobre la cual se efectúan los cambios. Por cuanto nuestra clase Student representa un estudiante, hemos nombrado a este primer argumento student. No obstante, por convención, se lo llama self ─es decir, «yo»─.

class Student:

    def __init__(self, name, subjects):
        if not isinstance(name, str):
            raise TypeError("name must be a string")
        elif not isinstance(subjects, list):
            raise TypeError("subjects must be a list")
        self.name = name
        self.subjects = subjects
    
    def print_info(self):
        print("Nombre: {}.".format(self.name))
        print("Asignaturas: {}.".format(self.subjects))

Por esta causa self no es ningún objeto mágico o palabra reservada. Sencillamente es una instancia de la clase desde la cual está siendo invocado. Recordemos que ambas llamadas a continuación son equivalentes.

Student.print_info(student)
student.print_info()

Así, considerando el siguiente código:

student.print_info()
student2.print_info()

En la primera llamada, self == student, mientras que en la segunda self == student2.

Encapsulamiento

En Python todos los métodos y atributos son públicos. Públicos, entendidos en la terminología de otros lenguajes orientados a objetos como Java o C++ que distinguen entre elementos públicos y privados. No obstante, como convención, se prefija un guión bajo a aquellos métodos o atributos que quieran ser catalogados como privados. En otras palabras, un guión bajo delante del nombre de un objeto indica que éste no debería ser utilizado desde fuera de la clase.

Por ejemplo, si bien hemos chequeado en la inicialización de Student que el atributo name sea una cadena, nada nos impide alterarlo luego por fuera de la clase.

student = Student("Pablo", [5, 14, 3])
student.name = 1  # No es una cadena.

Sería útil, por ende, crear los métodos set_name() y get_name() para hacer las comprobaciones pertinentes.

class Student:

    def __init__(self, name, subjects):
        self.set_name(name)
        if not isinstance(subjects, list):
            raise TypeError("subjects must be a list")
        self.subjects = subjects
    
    def set_name(self, name):
        if not isinstance(name, str):
            raise TypeError("name must be a string")
        self._name = name
    
    def get_name(self):
        return self._name

(De forma similar podría implementarse para el atributo subjects).

Luego, un intento por establecer un valor diferente a una cadena lanza el error correspondiente.

student.set_name(1)  # TypeError.

Curso online 👨‍💻

¡Ya lanzamos el curso oficial de Recursos Python en Udemy! Un curso moderno para aprender Python desde cero con programación orientada a objetos, SQL y tkinter en 2024.

Consultoría 💡

Ofrecemos servicios profesionales de desarrollo y capacitación en Python a personas y empresas. Consultanos por tu proyecto.

Deja una respuesta