¿Qué es la programación orientada a objetos (OOP)?

La programación orientada a objetos (OOP) se utiliza en todas partes. Las tecnologías orientadas a objetos se emplean para escribir sistemas operativos, software comercial y de código abierto. Sin embargo, las ventajas de la programación orientada a objetos solo se ponen de manifiesto cuando el proyecto alcanza un cierto nivel de complejidad. El estilo de programación orientado a objetos sigue siendo uno de los paradigmas de programación predominantes.

¿Qué es la programación orientada a objetos y para qué es necesaria?

El término “programación orientada a objetos” se acuñó a finales de los años sesenta por la leyenda de la programación Alan Kay, codesarrollador del pionero lenguaje de programación orientado a objetos Smalltalk. Este lenguaje, a su vez, había recibido la influenciado de Simula, el primer lenguaje con características OOP. Las ideas fundamentales de Smalltalk siguen influyendo en las características de OOP en los lenguajes de programación modernos como Ruby, Python, Go y Swift.

La programación orientada a objetos se sitúa junto a la popular programación funcional entre los paradigmas de programación predominantes. Los enfoques de programación pueden clasificarse en dos grandes corrientes: “imperativa” y “declarativa”. La OOP es una variante del estilo de programación imperativo y, concretamente, un desarrollo posterior de la programación procedimental:

  1. Programación imperativa: describe en pasos individuales cómo resolver un problema. Ejemplo: Algoritmo
  • Programación estructurada
    • Programación procedimental
      • Programación orientada a objetos
  1. Programación declarativa: genera resultados según determinadas reglas - Ejemplo: consulta SQL
  • Programación funcional
  • Programación específica del dominio
Nota

Los términos “procedimental” y “funcional” se utilizan a menudo como sinónimos. Los dos son bloques de código ejecutables que pueden recibir argumentos. La diferencia es que las funciones devuelven un valor, mientras que los procedimientos no. No todos los lenguajes proporcionan soporte explícito para los procedimientos.

En principio, es posible resolver cualquier problema de programación con cualquiera de los paradigmas, ya que todos los paradigmas son “Turing completos”. Por tanto, el elemento limitador no es la máquina, sino el ser humano. Los programadores o sus equipos solo pueden comprender cierta complejidad. Por este motivo, para poder dominarla, recurren a abstracciones. Dependiendo del ámbito de aplicación y del problema, será más recomendable usar uno u otro estilo de programación.

La mayoría de los lenguajes modernos son los llamados lenguajes multiparadigma, que permiten programar en varios estilos de programación. En cambio, hay lenguajes que solo admiten un único estilo de programación; lo que se aplica en el caso de los lenguajes estrictamente funcionales, como Haskell:

Paradigma Características Especialmente adecuado para Idiomas
Imperativo OOP Objetos, clases, métodos, herencia, polimorfismo Modelización, diseño de sistemas Smalltalk, Java, Ruby, Python, Swift
Imperativo Procedimental Flujo de control, iteración, procedimientos / funciones C, Pascal, Basic
Declarativo Funcional Inmutabilidad, funciones puras, cálculo lambda, recursión, sistemas de tipos Procesamiento paralelo de datos, aplicaciones matemáticas y científicas, analizadores sintácticos y compiladores Lisp, Haskell, Clojure
Declarativo Lenguaje específico del dominio (DSL) Expresivo, amplia gama de lenguaje Aplicaciones específicas del sector SQL, CSS
Nota

Sorprendentemente, incluso CSS es un lenguaje Turing completo. Es decir, cualquier cálculo escrito en otros lenguajes también podría resolverse en CSS.

La programación orientada a objetos forma parte de la programación imperativa y evolucionó a partir de la programación procedimental. Esta última trabaja con datos inertes que son procesados por un código ejecutable:

  1. Datos: valores, estructuras de datos, variables
  2. Código: expresiones, estructuras de control, funciones

Esta es precisamente la diferencia entre la programación orientada a objetos y la procedimental: la OOP combina datos y funciones en objetos. Un objeto es una estructura de datos viva, porque los objetos no son inertes, sino que tienen un comportamiento. Así, los objetos son comparables a las máquinas o a los organismos unicelulares. Mientras que con los datos solo se opera, con los objetos es posible interactuar (o los objetos interactúan entre sí).

Descubre la diferencia con un ejemplo. Una variable entera en Java o C++ solo contiene un valor. No es una estructura de datos, sino una “Primitive”:

int number = 42;
Java

Las operaciones sobre las Primitive se realizan a través de operadores o funciones que se definen fuera. Aquí, el ejemplo de la función successor, que devuelve el número que sigue a un entero:

int successor(int number) {
  return number + 1;
}

// returns `43`
successor(42)
Java

En cambio, en lenguajes como Python y Ruby, “todo es un objeto”. Incluso un simple número incluye el valor real, así como un conjunto de métodos que definen las operaciones sobre el valor. Aquí, el ejemplo de la función succ incorporada en Ruby:


42.succ
Ruby

En primer lugar, esto resulta práctico pues la funcionalidad de un tipo de datos está agrupada. No es posible llamar a un método que no coincida con el tipo. No obstante, los métodos pueden hacer aún más. En Ruby, incluso el bucle For se realiza como un método de un número. Como ejemplo, emitimos los números del 51 al 42:

51.downto(42) { |n| print n, ".. " }
Ruby

Entonces, ¿de dónde vienen los métodos? Los objetos se definen a través de clases en la mayoría de los lenguajes. Se dice que los objetos “se instancian” a partir de las clases y, por tanto, los objetos también se llaman instancias. Una clase es una plantilla para crear objetos similares que tienen los mismos métodos. Por lo tanto, en los lenguajes OOP puros, las clases funcionan como tipos. Esto queda claro en programación orientada a objetos en Python; la función type devuelve una clase como tipo de un valor:

type(42) # <class 'int'>
type('Walter White') # <class 'str'>
Python

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

Si le preguntas a una persona con poca experiencia en programación en qué consiste la programación orientada a objetos (OOP), la respuesta probablemente sea “algo sobre las clases”. Sin embargo, las clases no son el centro de la cuestión. Las ideas básicas de la programación orientada a objetos de Alan Kay son más sencillas y pueden resumirse como sigue:

  1. Los objetos encapsulan su estado interno.
  2. Los objetos reciben mensajes a través de sus métodos.
  3. Los métodos se asignan dinámicamente en tiempo de ejecución.

A continuación, se analizan estos tres puntos críticos.

Los objetos encapsulan su estado interno

Para entender lo que significa la encapsulación, te presentamos el ejemplo de un coche. Un coche tiene un estado determinado, por ejemplo, la carga de la batería, el nivel de carga del depósito, si el motor está en marcha o no. Si se representa un coche de este tipo como un objeto, las propiedades internas solo deberían poder cambiarse a través de interfaces definidas.

Aquí algunos ejemplos. Hay un objeto car que representa un coche. Dentro del objeto, el estado se almacena en variables. El objeto gestiona los valores de las variables; por ejemplo, es posible asegurarse de que la energía se utiliza para arrancar el motor. El motor del coche se arranca enviando un mensaje de start:

car.start()
Python

En este punto, el objeto decide lo que sucede a continuación: si el motor ya está en marcha, el mensaje se ignora o se emite un mensaje correspondiente. Si no hay suficiente carga en la batería o el depósito está vacío, el motor permanece apagado. Si se cumplen todas las condiciones, el motor se pone en marcha y se ajusta el estado interno. Por ejemplo, una variable booleana motor_running se establece en “True” y la carga de la batería se reduce en la carga necesaria para el arranque. A continuación, se muestra de forma esquemática cómo podría ser el código dentro del objeto:

# starting car
motor_running = True
battery_charge -= start_charge
Python

Es importante que el estado interno no pueda ser modificado directamente desde el exterior. De lo contrario, sería posible establecer motor_running como “True” incluso si la batería estuviera vacía, lo que no reflejaría las condiciones reales.

Envío de mensajes / métodos de llamada

Como se ha visto, los objetos reaccionan a los mensajes y pueden cambiar su estado interno como respuesta. Estos mensajes reciben el nombre de métodos; esto es, son funciones que están vinculadas a un objeto. El mensaje consiste en el nombre del método y posiblemente otros argumentos. El objeto receptor se llama receptor. A continuación, se muestra el esquema general de recepción de mensajes por parte de los objetos de la siguiente manera:

# call a method
receiver.method(args)
Python

Otro ejemplo: imagina que estás programando un smartphone. Los diferentes objetos representan funcionalidades, por ejemplo, las llamadas, la linterna, la libreta de direcciones, un mensaje de texto, etc. Por norma general, los subcomponentes individuales se modelan a su vez como objetos. Por lo tanto, la libreta de direcciones es un objeto, al igual que cada contacto que contiene y también el número de teléfono de un contacto. Esto facilita la modelización de los procesos a partir de la realidad:

# find a person in our address book
person = contacts.find('Walter White')
# let's call that person's work number
call = phone.call(person.phoneNumber('Work'))
...
# after some time, hang up the phone
call.hangUp()
Python

Asignación dinámica de los métodos

El tercer criterio esencial en la definición original de Alan Kay de la OOP es la asignación dinámica de métodos en tiempo de ejecución. Esto significa que la decisión sobre qué código se ejecuta cuando se llama a un método solo tiene lugar cuando se ejecuta el programa. En consecuencia, puedes modificar el comportamiento de un objeto en tiempo de ejecución.

La asignación dinámica de métodos tiene importantes implicaciones para la implementación técnica de la funcionalidad OOP en los lenguajes de programación, aunque en la práctica no se suele tener mucho que ver con ello. No obstante, aquí se presenta un ejemplo. Se modela la linterna del smartphone como un objeto flashlight que reacciona a los mensajes de on, off e intensity:

// turn on flashlight
flashlight.on()
// set flashlight intensity to 50%
flashlight.intensity(50)
// turn off flashlight
flashlight.off()
JavaScript

Imagina que la linterna se rompe y decides emitir una advertencia adecuada en caso de que se quiera acceder a ella. Un enfoque es sustituir todos los métodos por uno nuevo, lo que resulta muy sencillo, por ejemplo, en JavaScript. Se define la nueva función de “Fuera de servicio” con el nombre *out_of_order+ y se sobrescriben los métodos existentes con ella:

function out_of_order() {
  console.log('Flashlight out of order. Please service phone.')
  return false;
}

flashlight.on = out_of_order;
flashlight.off = out_of_order;
flashlight.intensity = out_of_order;
JavaScript

Si más tarde se intenta interactuar con la linterna, recibirás el aviso de out_of_order:

// calls `out_of_order()`
flashlight.on()
// calls `out_of_order()`
flashlight.intensity(50)
// calls `out_of_order()`
flashlight.off()
JavaScript

¿De dónde vienen los objetos? Instanciación e inicialización

Hasta ahora has visto cómo los objetos reciben mensajes y reaccionan ante ellos. Pero ¿de dónde vienen los objetos? Aquí se presenta la instanciación, un concepto central. La instanciación es el proceso por el que un objeto pasa a existir. En los diferentes lenguajes de programación orientada a objetos existen diferentes mecanismos de instanciación. Normalmente se utilizan uno o varios de los siguientes mecanismos:

  1. Definición por objeto literal
  2. Instanciación con función constructora
  3. Instanciación desde una clase

JavaScript destaca en este punto dado que los objetos como los números o las cadenas pueden definirse directamente como literales. Un ejemplo sencillo: se instancia un objeto vacío person y luego se le asigna un nombre, name, y un saludo, greet. Después, el objeto es capaz de saludar a otra persona y decir su propio nombre:

// instantiate empty object
let person = {};
// assign object property
person.name = "Jack";
// assign method
person.greet = function(other) {
  return `"Hi ${other}, I'm ${this.name}"`
};

// let's test
person.greet("Jim")
JavaScript

Se ha instanciado un objeto único. Sin embargo, a menudo se quiere repetir la instanciación para crear una serie de objetos similares. Para este caso, también se puede usar JavaScript. Se crea una función llamada constructor que ensambla un objeto cuando se llama. Al hacerlo, la función constructora llamada Person toma un nombre y una edad y crea un nuevo objeto:

function Person(name, age) {
  this.name = name;
  this.age = age;
  
  this.introduce_self = function() {
    return `"I'm ${this.name}, ${this.age} years old."`
  }
}

// instantiate person
person = new Person('Walter White', 42)
// let person introduce themselves
person.introduce_self()
JavaScript

Ten cuidado con el uso de la palabra clave “this”, que también se encuentra en otros lenguajes como Java, PHP y C++ y, a menudo, suele confundir a los menos experimentados en OOP. De forma resumida, se trata de un marcador de posición para un objeto instanciado. Cuando se llama a un método, this hace referencia al receptor, apuntando a una instancia específica del objeto. Otros lenguajes como Python y Ruby utilizan para el mismo propósito self en lugar de this.

Además, en JavaScript necesitamos la palabra clave “new” para crear la instancia del objeto correctamente. Esto se encuentra especialmente en Java y C++, que distinguen entre “Stack” y “Heap” a la hora de almacenar valores en memoria. En ambos lenguajes, new se utiliza para asignar memoria en heap. JavaScript, al igual que Python, almacena todos los valores en heap, por lo que new es realmente innecesario. Python demuestra que se puedes prescindir de él.

El tercer mecanismo, y el más extendido, para crear instancias de objetos hace uso de las clases. Una clase cumple un papel similar al de una función constructora en JavaScript: la dos sirven de modelo para poder instanciar objetos similares cuando sea necesario. Al mismo tiempo, en lenguajes como Python y Ruby, una clase funciona como sustituto de los tipos utilizados en otros lenguajes. A continuación, se muestra un ejemplo de clase.

¿Cuáles son las ventajas y desventajas de la OOP?

Desde los inicio del siglo XXI, la programación orientada a objetos ha ido recibiendo cada vez más críticas. Los lenguajes modernos y funcionales con inmutabilidad y sistemas de tipos fuertes se consideran más estables, fiables y eficaces. Sin embargo, la OOP se sigue usando en gran medida y tiene claras ventajas. Es importante elegir la herramienta adecuada para cada problema en lugar de confiar en una sola metodología.

Ventaja: encapsulación

Una ventaja inmediata de la OOP es la agrupación de la funcionalidad. En lugar de agrupar múltiples variables y funciones en una colección abierta, se pueden combinar en unidades coherentes. Se presenta la diferencia con un ejemplo: se modela un autobús y se utilizan dos variables y una función para ello. Los pasajeros, “passengers”, pueden subir al autobús hasta que esté lleno:

# list to hold the passengers
bus_passengers = []
# maximum number of passengers
bus_capacity = 12

# add another passenger
def take_bus(passenger)
  if len(bus_passengers) < bus_capacity:
    bus_passengers.append(passenger)
  else:
    raise Exception("Bus is full")
Python

El código funciona, pero resulta problemático. La función take_bus accede a las variables bus_passengers y bus_capacity sin pasarlas como argumentos. Esto conduce a problemas con el código extenso, ya que las variables deben proporcionarse globalmente o pasarse con cada llamada. Además, es posible “hacer trampa”; pues es posible seguir añadiendo pasajeros al autobús, aunque esté lleno:

# bus is full
assert len(bus_passengers) == bus_capacity
# will raise exception, won't add passenger
take_bus(passenger)
# we cheat, adding an additional passenger directly
bus_passengers.append(passenger)
# now bus is over capacity
assert len(bus_passengers) > bus_capacity
Python

Nada impide aumentar la capacidad del autobús. Sin embargo, esto viola las suposiciones sobre la realidad física, porque un autobús real tiene una capacidad limitada que no se puede cambiar a voluntad:

# can't do this in reality
bus_capacity += 1
Python

El encapsulamiento del estado interno de los objetos protege de los cambios absurdos o no deseados. Aquí está la misma funcionalidad en código orientado a objetos. Se define una clase de autobús y se instancia un autobús con capacidad limitada. Añadir pasajeros solo es posible a través del método correspondiente:

class Bus():
  def __init__(self, capacity):
    self._passengers = []
    self._capacity = capacity
  
  def enter(self, passenger):
    if len(self._passengers) < self._capacity:
      self._passengers.append(passenger)
      print(f"{passenger} has entered the bus")
    else:
      raise Exception("Bus is full")

# instantiate bus with given capacity
bus = Bus(2)
bus.enter("Jack")
bus.enter("Jim")
# will fail, bus is full
bus.enter("John")
Python

Ventaja: sistemas modelo

La programación orientada a objetos es especialmente adecuada para modelar sistemas. La OOP es intuitiva para las personas, ya que el ser humano piensa en términos de objetos que pueden ser categorizados. Los objetos pueden ser tanto elementos físicos como conceptos abstractos.

La herencia a través de jerarquías de clases que se encuentra en muchos lenguajes OOP también refleja los patrones de pensamiento humano. Por ejemplo: un animal es un concepto abstracto. Los animales que se producen realmente son siempre expresiones concretas de una especie. Dependiendo de la especie, los animales tienen diferentes características. Un perro no puede escalar ni volar, por lo que sus movimientos se limitan al espacio bidimensional:

# abstract base class
class Animal():
  def move_to(self, coords):
    pass

# derived class
class Dog(Animal):
  def move_to(self, coords):
    match coords:
      # dogs can't fly nor climb
      case (x, y):
        self._walk_to(coords)

# derived class
class Bird(Animal):
  def move_to(self, coords):
    match coords:
      # birds can walk
      case (x, y):
        self._walk_to(coords)
      # birds can fly
      case (x, z, y):
        self._fly_to(coords)
Python

Desventajas de la programación orientada a objetos

Una desventaja inmediata de la OOP es la jerga, que resulta difícil de entender al principio. Te ves obligado a aprender conceptos completamente nuevos, cuyo significado y propósito no suelen quedar claros con ejemplos simples. Es fácil cometer errores. De hecho, modelar jerarquías de herencia requiere mucha habilidad y experiencia.

Una de las críticas más frecuentes a la OOP es el encapsulamiento del estado interno, que en realidad pretende ser una ventaja. Esto conlleva dificultades a la hora de paralelizar el código OOP. Si un objeto se traslada a varias funciones paralelas, el estado interno podría cambiar entre las llamadas a las funciones. Además, a veces es necesario acceder a información encapsulada en otra parte de un programa.

La naturaleza dinámica de la programación orientada a objetos suele producir pérdidas de rendimiento, ya que se pueden realizar menos optimizaciones estáticas. Los sistemas de tipos de los lenguajes OOP puros, que tienden a ser menos pronunciados, también hacen imposibles algunas comprobaciones estáticas. Los errores solo se hacen visibles en tiempo de ejecución. Los nuevos desarrollos, como el lenguaje JavaScript TypeScript, contrarrestan esta situación.

¿Qué lenguajes de programación admiten o son adecuados para la OOP?

Casi todos los lenguajes multiparadigma son adecuados para la programación orientada a objetos. Entre ellos se encuentran los conocidos lenguajes de programación de Internet PHP, Ruby, Python y JavaScript. En cambio, los principios de la OOP son en gran medida incompatibles con el álgebra relacional subyacente a SQL. Para salvar el “desajuste de impedancias”, se utilizan capas de traducción especiales conocidas como “mapeadores de objetos relacionales” (ORM).

Ni siquiera los lenguajes puramente funcionales, como Haskell, suelen ofrecer soporte nativo para la OOP. Para implementar OOP en C, hay que hacer un pequeño esfuerzo. Curiosamente, Rust es un lenguaje moderno que prescinde de las clases. En su lugar, se utilizan struct y enum como estructuras de datos cuyo comportamiento se define mediante una palabra clave precedida por impl. Con los llamados rasgos se pueden agrupar los comportamientos; de esta manera también se representan la herencia y el polimorfismo. El diseño del lenguaje refleja el mantra de la programación orientada a objetos (OOP) “Composición antes que herencia”.