curso
POO en Java: Clases, objetos, encapsulación, herencia y abstracción
Java es sistemáticamente 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 no tiene parangón. Su sólido sistema tipográfico, su amplio ecosistema y su capacidad de "escribir una vez y ejecutar en cualquier lugar" lo hacen especialmente atractivo para construir 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 forma eficaz, permitiéndoles crear aplicaciones mantenibles y escalables mediante una organización y reutilización adecuadas del código.
Nota sobre la organización y ejecución del código Java
Antes de empezar a escribir código, vamos a hacer algunos ajustes.
Al igual que con su sintaxis, Java tiene reglas estrictas sobre la organización del código.
En primer lugar, cada clase pública debe estar en su propio archivo, con el mismo nombre que la clase pero con la extensión.java
. Así, si quiero escribir una clase de ordenador portátil , el nombre del archivo debe ser Laptop.java
-distinguiendo entre mayúsculas y minúsculas. Puedes tener clases no públicas en el mismo archivo, pero es mejor separarlas. Sé que nos estamos adelantando -hablando de organizar las clases incluso antes de escribirlas-, pero hacerse una idea aproximada de dónde colocar las cosas de antemano es una buena idea.
Todos los proyectos Java deben tener un archivo Main.java
con la claseMain. Aquí es donde pruebas tus clases creando objetos a partir de ellas.
Para ejecutar código Java, utilizaremos IntelliJ IDEAun popular IDE de Java. Después de instalar IntelliJ:
- Crea un nuevo proyecto Java (Archivo > Nuevo > Proyecto)
- Haz clic con el botón derecho del ratón en la carpeta src para crear el archivo
Main.java
y pega el siguiente contenido:
public class Main {
public static void main(String[] args) {
// Create and tests objects here
}
}
Cuando hablamos de clases, escribimos código en otros archivos distintos del archivoMain.java
. Pero si hablamos de crear y probarobjetos , pasamos a Main.java
.
Para ejecutar el programa, puedes hacer clic en el botón verde de reproducción situado junto al método principal:
El resultado se mostrará en la ventana Ejecutar herramienta de la parte inferior.
Si eres completamente nuevo en Java, consulta nuestro curso Curso de introducción a Javaque cubre los fundamentos de los tipos de datos y el flujo de control de Java antes de continuar.
Por lo demás, vamos a sumergirnos de lleno.
Clases y objetos Java
Entonces, ¿qué son exactamente las clases?
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 proporciona un plano o plantilla para representar varios elementos del menú de un restaurante. Cambiando los dos atributos de la clase, name
, y price
, podemos crear innumerables objetos de menú , como una hamburguesa o una ensalada.
Así pues, para crear una clase en Java, empieza una línea que describa el nivel de acceso de la clase (private
,public
, o protected
) seguida del nombre de la clase. Inmediatamente después de los corchetes, esboza los atributos de tu clase.
Pero, ¿cómo creamos objetos que pertenezcan a esta clase? Java lo permite mediante 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 le proporcionemos. 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 la 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 tras la definición de la clase. Arriba, el constructor está creando dos atributos porque declaramos dos después de la definición de la clase: name
y price
.
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) {
// Create objects here
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 objetos MenuItem
en las variables burger
y salad
. Como se exige en Java, hay que declarar el tipo de la variable, que es MenuItem
. A continuación, para crear una instancia de nuestra clase, escribimos la palabra clave new seguida de la invocación al método constructor.
Aparte del constructor, puedes crear métodos normales que den comportamiento a tu clase. Por ejemplo, a continuación, añadimos 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;
}
// Method to calculate price after tax
public double getPriceAfterTax() {
double taxRate = 0.08; // 8% tax rate
return price + (price * taxRate);
}
}
Ahora podemos calcular el precio total, impuestos incluidos:
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
Encapsulación
La finalidad de las clases es proporcionar un plano para crear objetos. Estos objetos serán luego utilizados por otros scripts o programas. Por ejemplo, nuestros objetos MenuItem
pueden ser utilizados por una interfaz de usuario que muestre su nombre, precio e imagen en una pantalla.
Por eso, debemos diseñar nuestras clases de forma que sus instancias sólo puedan utilizarse como nosotros pretendíamos. Ahora mismo, nuestra clase MenuItem
es muy básica y propensa a errores. Una persona puede crear objetos con atributos ridículos, como una tarta de manzana a precio negativo o un bocadillo de un millón de dólares:
// Inside Main.java
MenuItem applePie = new MenuItem("Apple Pie", -5.99); // Negative price!
MenuItem sandwich = new MenuItem("Sandwich", 1000000); // Unreasonably expensive
System.out.println("Apple pie price: $" + applePie.price);
System.out.println("Sandwich price: $" + sandwich.price);
Por tanto, lo primero que hay que hacer después de escribir una clase es proteger sus atributos limitando cómo se crean y cómo se accede a ellos. Para empezar, queremos permitir sólo valores positivos parael precio de y establecer un valor máximo para evitar mostrar accidentalmente artículos ridículamente caros.
Java nos permite conseguirlo 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. Hemos hecho privados los atributos añadiendo la palabra clave private
. Esto significa que sólo se puede acceder a ellos dentro de la clase MenuItem. La encapsulación comienza con este paso crucial.
2. Hemos añadido una nueva constante MAX_PRICE
que es:
- privado (sólo accesible dentro de la clase)
- estática (compartida por todas las instancias)
- final (no se puede cambiar después de la inicialización)
- fijado en 100,0$ como precio máximo razonable
3. Hemos añadido un método setPrice()
que:
- Toma un parámetro de precio
- Valida que el precio no sea negativo
- Valida que el precio no supera el PRECIO_MAX
- Lanza una IllegalArgumentException con mensajes descriptivos si falla la validación
- Sólo fija el precio si se superan todas las validaciones
4. Modificamos el constructor para utilizar setPrice()
en lugar de asignar directamente el precio. Esto garantiza que la validación del precio se produzca durante la creación del objeto.
Acabamos de implementar uno de los pilares básicos de un buen diseño orientado a objetos:la encapsulación. Este paradigma impone la ocultación de datos y el acceso controlado a los atributos de los objetos, garantizando que los detalles internos de implementación estén protegidos de interferencias externas y sólo puedan modificarse a través de interfaces bien definidas.
Concretemos el punto aplicando la encapsulación al atributonombre. Imagina que tenemos una cafetería que sólo sirve cafés con leche, capuchinos, expresos, americanos y mocas.
Por tanto, los nombres de nuestros elementos de menú sólo pueden ser uno de los elementos de esta lista. He aquí cómo podemos aplicar esto en el código:
// Rest of the class here
...
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ú de una cafetería. Vamos a desglosarlo:
1. En primer lugar, define una matriz final estática privada VALID_NAMES
que contenga los únicos nombres de bebida permitidos: latte, cappuccino, espresso, americano y mocha. Siendo este conjunto:
- privado: sólo accesible dentro de la clase
- estático: compartido por todas las instancias
- final: no se puede modificar después de la inicialización
2. Declara un campo privado String name para almacenar el nombre de la bebida
3. El método setName()
implementa la lógica de validación:
- Toma como parámetro un nombre de cadena
- Lo convierte a minúsculas para que la comparación no distinga entre mayúsculas y minúsculas
- Recorre la matriz
VALID_NAMES
- Si se encuentra una coincidencia, establece el nombre y 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 la forma en que se accede a ellos. Esto se hace utilizando métodos getter:
public class MenuItem {
// Rest of the code here
...
public String getName() {
return name;
}
public double getPrice() {
return price;
}
}
Los métodos Getter proporcionan un 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 directamente a los atributos:
MenuItem item = new MenuItem("Latte", 4.99);
String name = item.name; // Direct access to attribute
item.name = "INVALID"; // Can modify directly, bypassing validation
Con los getters, reforzamos el acceso adecuado:
MenuItem item = new MenuItem("Latte", 4.99);
String name = item.getName(); // Controlled access through getter
// item.name = "INVALID"; // Not allowed - must use setName() which validates
Esta encapsulación:
- Protege la integridad de los datos impidiendo modificaciones no válidas
- Nos permite cambiar la implementación interna sin afectar al código que utiliza la clase
- Proporciona un único punto de acceso que puede incluir lógica adicional si es necesario
- Hace que el código sea más fácil de mantener y menos propenso a errores
Herencia
Nuestra clase empieza a tener buen aspecto, pero tiene bastantes problemas. Por ejemplo, para un restaurante grande que sirve muchos tipos de platos y bebidas, la clase no es lo suficientemente flexible.
Si queremos añadir distintos tipos de alimentos, nos encontraremos con varios problemas. Algunos platos pueden prepararse para llevar, mientras que otros necesitan un consumo inmediato. Los artículos del menú pueden tener precios y descuentos variables. Los platos pueden necesitar un seguimiento de la temperatura o un almacenamiento especial. Las bebidas pueden ser frías o calientes, con ingredientes personalizables. Los productos pueden necesitar información sobre alérgenos y opciones de raciones. El sistema actual no gestiona estos requisitos variables.
La herencia ofrece 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 creando después clases hijas que hereden estos elementos básicos añadiendo al mismo tiempo características únicas.
Por ejemplo, podríamos tener una clase Drink
para bebidas con opciones de temperatura, una clase Food
para artículos que necesiten un consumo inmediato y una clase Dessert
para artículos con necesidades especiales de almacenamiento, todas ellas heredando la funcionalidad básica de los elementos del menú.
Ampliación de clases
Pongamos en práctica esas ideas empezando por 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 hija que hereda de una clase padre, utilizamos la palabra claveextends
después del nombre de la clase hija, seguida de la clase padre. Tras la definición de la clase, definimos los nuevos atributos que tenga este hijo e implementamos su constructor.
Pero fíjate en que tenemos que repetir la inicialización de name
y price
junto con isCold
. Eso no es lo ideal, porque la clase padre puede tener cientos de atributos. Además, el código anterior arrojará un error cuando lo compiles, porque esa no es la forma correcta de inicializar atributos de la clase padre. La forma correcta sería utilizando 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 clavesuper
se utiliza para llamar al constructor de la clase padre. En este caso, super(name, price)
llama al constructor deMenuItem
para inicializar esos atributos, evitando la duplicación de código. Sólo tenemos que inicializar el nuevo atributo isCold
específico de la claseDrink
.
La palabra clave es muy flexible porque puedes utilizarla para hacer referencia a la clase padre en cualquier parte de la clase hija. Por ejemplo, para llamar a un método padre, utilizas super.methodName()
mientras que super.attributeName
es para los atributos.
Anulación de métodos
Ahora, supongamos que queremos añadir un nuevo método a nuestras clases para calcular el precio total después de impuestos. Como los distintos elementos del menú pueden tener tipos impositivos diferentes (por ejemplo, comida preparada frente a bebidas envasadas), podemos utilizar la sustitución de métodos para implementar cálculos de impuestos específicos en cada clase hija, manteniendo un nombre de método común en la clase padre.
Esto es lo que parece:
public class MenuItem {
// Rest of the MenuItem class
public double calculateTotalPrice() {
// Default tax rate of 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() {
// Food has 15% tax
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() {
// Drinks have 8% tax
return super.getPrice() * 1.08;
}
}
En este ejemplo, la sobreescritura de métodos permite a cada subclase proporcionar su propia implementación de calculateTotalPrice()
:
La clase MenuItem
define un cálculo de impuestos por defecto del 10%.
Cuando Food
y Drink
extienden MenuItem
anulan este método para aplicar sus propios tipos impositivos:
- Los alimentos tienen un tipo impositivo del 15% más alto
- Las bebidas tienen un tipo impositivo inferior del 8
La anotación@Override
se utiliza para indicar explícitamente que estos métodos sustituyen al método de la clase padre. Esto ayuda a detectar errores si la firma del método no coincide con la clase padre.
Cada subclase puede seguir accediendo al precio de la clase padre utilizando super.getPrice()
lo que demuestra cómo los métodos anulados pueden utilizar la funcionalidad de la clase padre añadiendo su propio comportamiento.
En resumen, la sobreescritura de métodos es una parte integral de la herencia que permite a las subclases proporcionar su implementación de métodos definidos en la clase padre, permitiendo un comportamiento más específico mientras se mantiene la misma firma de método.
Clases abstractas
Nuestra jerarquía de clases MenuItem
funciona, pero hay un problema: ¿cualquiera debería poder crear un objetoMenuItem
sin más ? Al fin y al cabo, en nuestro restaurante, cada elemento del menú es una Comida o una Bebida: no existe un "elemento genérico del menú".
Podemos evitarlo haciendo que MenuItem
sea una clase abstracta. Una clase abstracta sólo proporciona un plano base: sólo puede utilizarse como clase padre para la herencia, no instanciarse directamente.
Para hacer MenuItem
abstracto, añadimos 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);
}
// Existing getters/setters remain the same
// Make this method abstract - every subclass MUST implement it
public abstract double calculateTotalPrice();
}
Las clases abstractas también pueden tener métodos abstractos como calculateTotalPrice()
más arriba. Estos métodos abstractos sirven como contratos que obligan a las subclases a proporcionar sus implementaciones. En otras palabras, cualquier método abstracto de una clase abstracta debe ser implementado por las clases hijas.
Entonces, reescribamos Food
y Drink
teniendo en cuenta estos cambios:
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% tax
}
}
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% tax
}
}
A través de 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 empresariales.
Conclusión
Hoy hemos echado un vistazo a lo que Java es capaz de hacer como lenguaje de programación orientado a objetos. Hemos cubierto los fundamentos de las clases, los objetos y algunos pilares clave de la programación orientada a objetos: encapsulación, herencia y abstracción mediante un sistema de menú de restaurante.
Para que este sistema esté listo para la producción, aún te quedan muchas cosas por aprender, como las interfaces (parte de la abstracción), el polimorfismo y los patrones de diseño de la programación orientada a objetos. Para saber más sobre estos conceptos, consulta nuestra Introducción a la programación orientada a objetos en Java en Java.
Si quieres poner a prueba tus conocimientos de Java, intenta responder a algunas de las preguntas de nuestro artículo Preguntas para entrevistas sobre Java.
Preguntas frecuentes sobre la programación orientada a objetos en Java
¿Cuáles son los requisitos previos para seguir este tutorial de Java OOP?
Debes tener conocimientos básicos de programación en Java, incluyendo tipos de datos, variables, flujo de control (if/else, bucles) y sintaxis básica. Nuestro curso Introducción a Java cubre estos fundamentos si necesitas un repaso.
¿Por qué utilizar el sistema de menús de un restaurante para explicar conceptos de programación orientada a objetos?
El sistema de menús de un restaurante es un ejemplo intuitivo que demuestra las aplicaciones reales de los principios de la programación orientada a objetos. Muestra de forma natural la herencia (distintos tipos de elementos de menú), la encapsulación (protección de los valores de precio y nombre) y las interfaces (programas de fidelización, control de temperatura). La mayoría de la gente entiende cómo funcionan los restaurantes, lo que facilita la comprensión de los conceptos.
¿Cuál es la diferencia entre clases abstractas e interfaces en Java?
- Una clase sólo puede extender una clase abstracta pero implementar varias interfaces.
- Las clases abstractas pueden tener campos y constructores; las interfaces, no.
- Las clases abstractas pueden tener métodos abstractos y concretos; las interfaces tradicionalmente sólo tienen métodos abstractos (antes de Java 8).
- Las clases abstractas proporcionan una implementación base, mientras que las interfaces definen un contrato.

Soy un creador de contenidos de ciencia de datos con más de 2 años de experiencia y uno de los mayores seguidores en Medium. Me gusta escribir artículos detallados sobre IA y ML con un estilo un poco sarcastıc, porque hay que hacer algo para que sean un poco menos aburridos. He publicado más de 130 artículos y un curso DataCamp, y estoy preparando otro. Mi contenido ha sido visto por más de 5 millones de ojos, 20.000 de los cuales se convirtieron en seguidores tanto en Medium como en LinkedIn.
Los mejores cursos de Java
curso
Introducción a la programación orientada a objetos en Java
curso