OOP en Java: Clases, Objetos, Encapsulamiento, Herencia y Abstracción

Java es consistentemente uno de los tres lenguajes más populares del mundo. Su adopción en campos como el desarrollo de software empresarial, aplicaciones móviles Android y aplicaciones web a gran escala es incomparable. Su sólido sistema de tipos, ecosistema extenso y capacidad de “escribir una vez, ejecutar en cualquier lugar” lo hacen particularmente atractivo para la construcción de sistemas robustos y escalables. En este artículo, exploraremos cómo las características de programación orientada a objetos de Java permiten a los desarrolladores aprovechar estas capacidades de manera efectiva, lo que les permite construir aplicaciones mantenibles y escalables a través de una organización adecuada del código y la reutilización.

Una nota sobre la organización y ejecución del código Java

Antes de comenzar a escribir cualquier código, hagamos algunas configuraciones.

Al igual que con su sintaxis, Java tiene reglas estrictas sobre la organización del código.

Primero, cada clase pública debe estar en su propio archivo, nombrado exactamente como la clase pero con una extensión.java. Entonces, si quiero escribir una clase Laptop, el nombre de archivo debe serLaptop.javasensible a mayúsculas y minúsculas. Puedes tener clases no públicas en el mismo archivo, pero es mejor separarlas. Sé que estamos avanzando—hablando de organizar clases incluso antes de escribirlas—pero tener una idea aproximada de dónde poner las cosas de antemano es una buena idea.

Todos los proyectos de Java deben tener un archivo Main.java con la clase Main. Aquí es donde se prueban las clases creando objetos a partir de ellas.

Para ejecutar código Java, usaremos IntelliJ IDEA, un IDE de Java popular. Después de instalar IntelliJ:

  1. Crea un nuevo proyecto Java (Archivo > Nuevo > Proyecto)
  2. Haz clic derecho en la carpeta src para crear el Main.java archivo y pega el siguiente contenido:
public class Main { public static void main(String[] args) { // Crea y prueba objetos aquí } }

Cuando hablamos de clases, escribimos código en otros archivos que no sean el Main.java. Pero si hablamos de crear y probar objetos, cambiamos a Main.java.

Para ejecutar el programa, puedes hacer clic en el botón verde de reproducción junto al método principal:

La salida se mostrará en la ventana de herramientas Run en la parte inferior.

Si eres completamente nuevo en Java, por favor revisa nuestro Curso de Introducción a Java, que cubre los fundamentos de los tipos de datos y el flujo de control en Java antes de continuar.

De lo contrario, vamos directo al grano.

Clases y Objetos en Java

Entonces, ¿qué son las clases, exactamente?

Las clases son construcciones de programación en Java para representar conceptos del mundo real. Por ejemplo, considera esta clase MenuItem (crea un archivo para escribir esta clase en tu IDE):

public class MenuItem { public String name; public double price; }

La clase nos brinda un plano o una plantilla para representar varios elementos del menú en un restaurante. Al cambiar los dos atributos de la clase, nombre, y precio, podemos crear incontables objetos de menú como una hamburguesa o una ensalada.

Entonces, para crear una clase en Java, comienzas una línea que describe el nivel de acceso de la clase (private, public, o protected) seguido por el nombre de la clase. Inmediatamente después de los corchetes, esbozas los atributos de tu clase.

Pero, ¿cómo creamos objetos que pertenecen a esta clase? Java permite esto a través de métodos constructores:

public class MenuItem { public String name; public double price; // Constructor public MenuItem(String name, double price) { this.name = name; this.price = price; } }

Un constructor es un método especial que se llama cuando creamos un nuevo objeto a partir de una clase. Inicializa los atributos del objeto con los valores que proporcionamos. En el ejemplo anterior, el constructor toma un parámetro de nombre y precio y los asigna a los campos del objeto utilizando la palabra clave ‘this’ para referirse a una futura instancia del objeto.

La sintaxis del constructor es diferente a la de otros métodos de clase porque no requiere que especifiques un tipo de retorno. Además, el constructor debe tener el mismo nombre que la clase y debe tener el mismo número de atributos que declaraste después de la definición de la clase. En el ejemplo anterior, el constructor está creando dos atributos porque declaramos dos después de la definición de la clase: nombre y precio.

Después de escribir tu clase y su constructor, puedes crear instancias (objetos) de ella en tu método principal:

public class Main { public static void main(String[] args) { // Crea objetos aquí MenuItem burger = new MenuItem("Burger", 3.5); MenuItem salad = new MenuItem("Salad", 2.5); System.out.println(burger.name + ", " + burger.price); } }

Salida:

Burger, 3.5

Arriba, estamos creando dos MenuItem objetos en las variables burger y salad. Como se requiere en Java, el tipo de variable debe declararse, que es MenuItem. Luego, para crear una instancia de nuestra clase, escribimos la palabra clave new seguida de la invocación del método constructor.

Además del constructor, puedes crear métodos regulares que le den comportamiento a tu clase. Por ejemplo, a continuación, agregamos un método para calcular el precio total después de impuestos:

public class MenuItem { public String name; public double price; // Constructor public MenuItem(String name, double price) { this.name = name; this.price = price; } // Método para calcular el precio después de impuestos public double getPriceAfterTax() { double taxRate = 0.08; // Tasa impositiva del 8% return price + (price * taxRate); } }

Ahora podemos calcular el precio total, incluyendo impuestos:

public class Main { public static void main(String[] args) { MenuItem burger = new MenuItem("Burger", 3.5); System.out.println("Price after tax: $" + burger.getPriceAfterTax()); } }

Salida:

Price after tax: $3.78

Encapsulamiento

El propósito de las clases es proporcionar un plano para crear objetos. Estos objetos luego serán utilizados por otros scripts o programas. Por ejemplo, nuestros objetos MenuItem pueden ser utilizados por una interfaz de usuario que muestra su nombre, precio e imagen en una pantalla.

Por esta razón, debemos diseñar nuestras clases de tal manera que sus instancias solo puedan ser utilizadas como fue nuestra intención. En este momento, nuestra MenuItem clase es muy básica y propensa a errores. Una persona podría crear objetos con atributos ridículos, como un pastel de manzana con precio negativo o un sándwich de un millón de dólares:

// Dentro de Main.java MenuItem applePie = new MenuItem("Apple Pie", -5.99); // ¡Precio negativo! MenuItem sandwich = new MenuItem("Sandwich", 1000000); // Excesivamente caro System.out.println("Apple pie price: $" + applePie.price); System.out.println("Sandwich price: $" + sandwich.price);

Entonces, el primer asunto después de escribir una clase es proteger sus atributos limitando cómo se crean y acceden. Para empezar, queremos permitir solo valores positivos para precio y establecer un valor máximo para evitar mostrar accidentalmente artículos ridículamente caros.

Java nos permite lograr esto utilizando métodos setter:

public class MenuItem { private String name; private double price; private static final double MAX_PRICE = 100.0; public MenuItem(String name, double price) { this.name = name; setPrice(price); } public void setPrice(double price) { if (price < 0) { throw new IllegalArgumentException("Price cannot be negative"); } if (price > MAX_PRICE) { throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE); } this.price = price; } }

Examinemos qué hay de nuevo en el bloque de código anterior:

1. Hicimos los atributos privados agregando la palabra clave private. Esto significa que solo se pueden acceder dentro de la clase MenuItem. La encapsulación comienza con este paso crucial.

2. Agregamos una nueva constante MAX_PRICE que es:

  • privada (solo accesible dentro de la clase)
  • estático (compartido entre todas las instancias)
  • final (no se puede cambiar después de la inicialización)
  • establecido en $100.0 como un precio máximo razonable

3. Agregamos un setPrice() método que:

  • Toma un parámetro de precio
  • Valida que el precio no sea negativo
  • Valida que el precio no exceda MAX_PRICE
  • Lanza IllegalArgumentException con mensajes descriptivos si la validación falla
  • Solo establece el precio si todas las validaciones pasan

4. Modificamos el constructor para usar setPrice() en lugar de asignar directamente el precio. Esto garantiza que la validación del precio ocurra durante la creación del objeto.

Acabamos de implementar uno de los pilares fundamentales de un buen diseño orientado a objetos — encapsulación. Este paradigma garantiza el ocultamiento de datos y el acceso controlado a los atributos del objeto, asegurando que los detalles de implementación internos estén protegidos de interferencias externas y solo puedan ser modificados a través de interfaces bien definidas.

Reforzaremos este punto aplicando encapsulación al atributo nombre. Imaginemos que tenemos una cafetería que solo sirve lattes, cappuccinos, espressos, americanos y mochas.

Entonces, los nombres de nuestros elementos de menú solo pueden ser uno de los elementos de esta lista. Así es como podemos hacer cumplir esto en el código:

// Resto de la clase aquí ... private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"}; private String name; public void setName(String name) { String lowercaseName = name.toLowerCase(); for (String validName : VALID_NAMES) { if (validName.equals(lowercaseName)) { this.name = name; return; } } throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES)); }

El código anterior implementa la validación de nombres para los elementos del menú en una cafetería. Veámoslo detalladamente:

1. Primero, define un array final estático privado VALID_NAMES que contiene los únicos nombres de bebidas permitidos: latte, cappuccino, espresso, americano y mocha. Este array siendo:

  • privado: solo accesible dentro de la clase
  • estático: compartido entre todas las instancias
  • final: no se puede modificar después de la inicialización

2. Declara un campo String privado nombre para almacenar el nombre de la bebida

3. El método setName() implementa la lógica de validación:

  • Toma un parámetro de nombre String
  • Lo convierte a minúsculas para hacer la comparación insensible a mayúsculas y minúsculas
  • Recorre el array VALID_NAMES
  • Si se encuentra una coincidencia, establece el nombre y lo devuelve
  • Si no se encuentra ninguna coincidencia, lanza una IllegalArgumentException con un mensaje descriptivo que enumera todas las opciones válidas

Aquí está la clase completa hasta ahora:

public class MenuItem { private String name; private double price; private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"}; private static final double MAX_PRICE = 100.0; public MenuItem(String name, double price) { setName(name); setPrice(price); } public void setName(String name) { String lowercaseName = name.toLowerCase(); for (String validName : VALID_NAMES) { if (validName.equals(lowercaseName)) { this.name = name; return; } } throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES)); } public void setPrice(double price) { if (price < 0) { throw new IllegalArgumentException("Price cannot be negative"); } if (price > MAX_PRICE) { throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE); } this.price = price; } }

Después de proteger la forma en que se crean los atributos, también queremos proteger cómo se acceden. Esto se hace mediante métodos de obtención:

public class MenuItem { // Resto del código aquí ... public String getName() { return name; } public double getPrice() { return price; } }

Los métodos de obtención proporcionan acceso controlado a los atributos privados de una clase. Resuelven el problema del acceso directo a los atributos que puede provocar modificaciones no deseadas y romper la encapsulación.

Por ejemplo, sin getters, podríamos acceder a los atributos directamente:

MenuItem item = new MenuItem("Latte", 4.99); String name = item.name; // Acceso directo al atributo item.name = "INVALID"; // Puede modificarse directamente, evitando la validación

Con getters, aplicamos un acceso adecuado:

MenuItem item = new MenuItem("Latte", 4.99); String name = item.getName(); // Acceso controlado a través del getter // item.name = "INVALID"; // No permitido - se debe usar setName() que valida

Esta encapsulación:

  1. Protege la integridad de los datos al prevenir modificaciones inválidas
  2. Permite cambiar la implementación interna sin afectar el código que usa la clase
  3. Proporciona un único punto de acceso que puede incluir lógica adicional si es necesario
  4. Hace que el código sea más mantenible y menos propenso a errores

Herencia

Nuestra clase comienza a verse bien, pero hay varios problemas con ella. Por ejemplo, para un restaurante grande que sirve muchos tipos de platos y bebidas, la clase no es lo suficientemente flexible.

Si queremos agregar diferentes tipos de alimentos, nos encontraremos con varios desafíos. Algunos platos pueden estar preparados para llevar, mientras que otros necesitan ser consumidos de inmediato. Los elementos del menú pueden tener precios y descuentos variables. Los platos pueden necesitar seguimiento de temperatura o almacenamiento especial. Las bebidas pueden ser calientes o frías con ingredientes personalizables. Los elementos pueden necesitar información sobre alérgenos y opciones de porción. El sistema actual no maneja estos requisitos variables.

La herencia proporciona una solución elegante a todos estos problemas. Nos permite crear versiones especializadas de elementos de menú definiendo una clase base MenuItem con atributos comunes y luego creando clases hijas que heredan estos conceptos básicos mientras agregan características únicas.

Por ejemplo, podríamos tener una clase Drink para bebidas con opciones de temperatura, una clase Food para artículos que requieren consumo inmediato y una clase Dessert para artículos con necesidades especiales de almacenamiento, todas heredando la funcionalidad básica del elemento de menú.

Extensión de clases

Implementemos esas ideas comenzando con Drink:

public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { this.name = name; this.price = price; this.isCold = isCold; } public boolean getIsCold() { return isCold; } public void setIsCold(boolean isCold) { this.isCold = isCold; } }

Para definir una clase secundaria que hereda de una clase padre, usamos la palabra clave extends después del nombre de la clase secundaria seguido por el de la clase padre. Después de la definición de la clase, definimos cualquier atributo nuevo que tenga esta clase hija e implementamos su constructor.

Pero fíjate cómo tenemos que repetir la inicialización de nombre y precio junto con isCold. Eso no es ideal porque la clase principal podría tener cientos de atributos. Además, el código anterior lanzará un error al compilarlo porque no es la forma correcta de inicializar atributos de la clase principal. La forma correcta sería usando la palabra clave super:

public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { super(name, price); this.isCold = isCold; } public boolean getIsCold() { return isCold; } public void setIsCold(boolean isCold) { this.isCold = isCold; } }

La palabra clave super se utiliza para llamar al constructor de la clase padre. En este caso, super(nombre, precio) llama al constructor de MenuItem para inicializar esos atributos, evitando la duplicación de código. Solo necesitamos inicializar el nuevo atributo isCold específico de la clase Drink.

La palabra clave es muy flexible porque se puede usar para hacer referencia a la clase principal en cualquier parte de la clase secundaria. Por ejemplo, para llamar a un método de la clase principal, se usa super.nombreDelMetodo() mientras que super.nombreDelAtributo es para atributos.

Sobreescritura de métodos

Ahora, digamos que queremos agregar un nuevo método a nuestras clases para calcular el precio total después de impuestos. Dado que diferentes elementos del menú pueden tener diferentes tasas impositivas (por ejemplo, comida preparada vs. bebidas empaquetadas), podemos usar la sobreescritura de métodos para implementar cálculos de impuestos específicos en cada clase secundaria mientras mantenemos un nombre de método común en la clase principal.

Aquí tienes cómo se ve esto:

public class MenuItem { // Resto de la clase MenuItem public double calculateTotalPrice() { // Tasa de impuestos predeterminada del 10% return price * 1.10; } } public class Food extends MenuItem { private boolean isVegetarian; public Food(String name, double price, boolean isVegetarian) { super(name, price); this.isVegetarian = isVegetarian; } @Override public double calculateTotalPrice() { // La comida tiene un impuesto del 15% return super.getPrice() * 1.15; } } public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { super(name, price); this.isCold = isCold; } @Override public double calculateTotalPrice() { // Las bebidas tienen un impuesto del 8% return super.getPrice() * 1.08; } }

En este ejemplo, la anulación de métodos permite que cada subclase proporcione su propia implementación de calcularPrecioTotal():

La clase MenuItem define un cálculo de impuestos predeterminado del 10%.

Cuando Food y Drink extienden las clases MenuItem, sobrescriben este método para implementar sus propias tasas impositivas:

  • Los productos alimenticios tienen una tasa impositiva más alta del 15%
  • Las bebidas tienen una tasa impositiva del 8% más baja

La @Override anotación se utiliza para indicar explícitamente que estos métodos están anulando el método de la clase padre. Esto ayuda a detectar errores si la firma del método no coincide con la de la clase padre.

Cada subclase aún puede acceder al precio de la clase padre usando super.getPrice(), demostrando cómo los métodos anulados pueden utilizar la funcionalidad de la clase padre mientras agregan su comportamiento.

En resumen, la sobreescritura de métodos es una parte integral de la herencia que permite a las subclases proporcionar su propia implementación de los métodos definidos en la clase padre, habilitando un comportamiento más específico mientras se mantiene la misma firma de método.

Clases Abstractas

Nuestra MenuItem la jerarquía de clases funciona, pero hay un problema: ¿debería alguien poder crear un objeto MenuItem simple? Después de todo, en nuestro restaurante, cada elemento del menú es o bien un Comida o una Bebida – no existe algo como un “elemento de menú genérico”.

Podemos prevenir esto haciendo MenuItem una clase abstracta. Una clase abstracta proporciona solo un esquema base: solo puede ser utilizada como una clase padre para la herencia, no instanciada directamente.

Para hacer MenuItem abstracto, agregamos la palabra clave abstract después de su modificador de acceso:

public abstract class MenuItem { private String name; private double price; public MenuItem(String name, double price) { setName(name); setPrice(price); } // Los getters/setters existentes permanecen iguales // Hacer este método abstracto - cada subclase DEBE implementarlo public abstract double calculateTotalPrice(); }

Las clases abstractas también pueden tener métodos abstractos como calculateTotalPrice() arriba. Estos métodos abstractos sirven como contratos que obligan a las subclases a proporcionar sus implementaciones. En otras palabras, cualquier método abstracto en una clase abstracta debe ser implementado por las clases hijas.

Entonces, reescribamos Comida y Bebida con estos cambios en mente:

public class Food extends MenuItem { private boolean isVegetarian; public Food(String name, double price, boolean isVegetarian) { super(name, price); this.isVegetarian = isVegetarian; } @Override public double calculateTotalPrice() { return getPrice() * 1.15; // 15% de impuestos } } public class Drink extends MenuItem { private boolean hasCaffeine; public Drink(String name, double price, boolean hasCaffeine) { super(name, price); this.hasCaffeine = hasCaffeine; } @Override public double calculateTotalPrice() { return getPrice() * 1.10; // 10% de impuestos } }

Mediante esta implementación del sistema de menús, hemos visto cómo la abstracción y la herencia trabajan juntas para crear un código flexible y mantenible que puede adaptarse fácilmente a diferentes requisitos comerciales.

Conclusión

Hoy, hemos echado un vistazo a lo que Java es capaz como lenguaje de programación orientado a objetos. Hemos cubierto los conceptos básicos de clases, objetos y algunos pilares clave de la POO: encapsulación, herencia y abstracción a través de un sistema de menú de restaurante.

Para que este sistema esté listo para producción, aún tienes mucho por aprender, como interfaces (parte de la abstracción), polimorfismo y patrones de diseño de POO. Para obtener más información sobre estos conceptos, consulta nuestro Introducción a la POO en Java curso.

Si deseas poner a prueba tus conocimientos de Java, intenta responder algunas de las preguntas en nuestro artículo de Preguntas de Entrevista de Java.

Source:
https://www.datacamp.com/tutorial/oop-in-java