Apuntes · Capítulo 1Objetos y clases

De C a Java: objetos como estado y comportamiento

De C a Java: objetos como estado y comportamiento

En el curso de introducción a la programación aprendieron a resolver problemas con variables, condicionales, bucles, funciones y arreglos, en C o en C++. Todo eso sigue valiendo. Este curso agrega una forma distinta de organizar los programas: en lugar de datos por un lado y funciones que los manipulan por otro, vamos a construir objetos que guardan sus datos y saben qué hacer con ellos. Usaremos Java 25, la versión con soporte de largo plazo (LTS) más reciente.

Este primer capítulo tiene dos partes. En la primera pasamos de C a Java: qué cambia al compilar, cómo se ejecuta un programa en la máquina virtual de Java, qué son los tipos primitivos y las referencias, y por qué en Java no hay punteros ni free. En la segunda respondemos la pregunta que da nombre al curso: ¿por qué objetos? Tomamos un ejemplo pequeño, lo escribimos primero al estilo C, con un struct y funciones, y después como una clase.

Lectura. Este capítulo acompaña a Eck, Introduction to Programming Using Java (versión 9), secciones 1.3 The Java Virtual Machine, 1.5 Objects and Object-oriented Programming, 2.1 a 2.3, la sección 2.6 Programming Environments (en especial 2.6.2 y 2.6.6, sobre jshell) y las secciones 5.1 y 5.2; si quieren repasar arreglos, la 3.8. El libro cubre Java 17: los archivos fuente compactos con void main() y la clase IO son de Java 25 y no aparecen ahí. El código del capítulo está en code/week-01 y se prueba con make test dentro del contenedor del curso (env/run.sh).

Ruta de lectura.

  • Antes de clase (obligatorio, 15–20 min): El curso en una página, Compilar a bytecode, no a código máquina y El problema de los datos sueltos.
  • En clase: en la sesión A, el resto de De C a Java: las tres herramientas, los archivos fuente compactos, los tipos primitivos, las referencias y el garbage collector; en la sesión B, Objetos como estado y comportamiento, de La idea: juntar estado y comportamiento a Mensajes, estado e identidad. En el laboratorio, El código de la semana y make test.
  • Después de clase: Ejemplo resuelto: la tarjeta en jshell, el Resumen y los Ejercicios. Para ampliar, las secciones de Eck de la nota Lectura.

El curso en una página

El curso tiene cuatro partes: objetos y clases (semanas 1 a 4), herencia, polimorfismo e interfaces (5 a 8), programar con tipos: excepciones, genéricos, colecciones, lambdas y streams (9 a 12), y diseño: principios, patrones y pruebas (13 a 16). El calendario completo, las lecturas de cada semana y las políticas están en el programa del curso.

Todo el semestre trabajarán en un solo proyecto que crece: Bodega, el sistema de inventario y ventas de una tienda pequeña. Empieza con unos programas cortos y una primera clase (P0) y termina como una aplicación de línea de comandos con persistencia en archivos y patrones de diseño (P5). Cada entrega debe pasar sus pruebas y las de las entregas anteriores, así que el código que escriban en la semana 2 lo van a seguir usando en la semana 16. Eso obliga a escribirlo bien.

Además del proyecto, hay un laboratorio cada semana, un examen parcial en la semana 8 y un examen final al cierre del curso.

El entorno es el mismo para todos: el JDK 25 y JUnit dentro de un contenedor Docker que funciona igual en Linux, Windows (WSL2) y macOS. Lo instalan en el laboratorio de esta semana.

De C a Java

Java nació en Sun Microsystems, en un equipo dirigido por James Gosling, y se publicó en 1995. La idea que lo hizo popular fue escribir una vez, ejecutar en cualquier parte: el mismo programa compilado tenía que correr sin cambios en máquinas con procesadores y sistemas operativos distintos. Hoy Java lo mantiene Oracle junto con la comunidad OpenJDK; sale una versión nueva cada seis meses y, cada dos años, una LTS. Java 25 (septiembre de 2025) es la LTS actual.

Retrato de James Gosling, con lentes, barba canosa y polo oscuro, hablando frente a un público
James Gosling, creador de Java, en 2008. Foto: Peter Campbell, CC BY-SA 4.0, Wikimedia Commons.

La sintaxis les va a resultar familiar: Java tomó de C las llaves, el punto y coma, if, while, for, los operadores y casi todos los tipos numéricos. Lo que cambia es lo que pasa por debajo.

Compilar a bytecode, no a código máquina

En C, gcc traduce el archivo fuente a código máquina de un procesador concreto. El ejecutable que sale de una laptop con procesador x86-64 no corre en una con procesador ARM: hay que volver a compilar en cada plataforma.

Java agrega un nivel intermedio. El compilador javac no produce código máquina sino bytecode: instrucciones para una computadora imaginaria, la máquina virtual de Java (Java Virtual Machine, JVM). El bytecode se guarda en archivos .class. Cada plataforma tiene su propia JVM, que es un programa común y corriente compilado para ese procesador, y esa JVM es la que ejecuta el bytecode. Por eso el mismo .class corre en todas.

Animación: arriba, card.c pasa por gcc a un ejecutable de código máquina x86-64, que corre en una CPU x86-64 pero no en una ARM. Abajo, Hello.java pasa por javac a Hello.class (bytecode), y ese mismo archivo llega a tres JVM: Linux x86-64, macOS ARM y Windows x86-64Animación: arriba, card.c pasa por gcc a un ejecutable de código máquina x86-64, que corre en una CPU x86-64 pero no en una ARM. Abajo, Hello.java pasa por javac a Hello.class (bytecode), y ese mismo archivo llega a tres JVM: Linux x86-64, macOS ARM y Windows x86-64
En C se compila una vez por procesador. En Java se compila una vez a bytecode, y la JVM de cada plataforma lo ejecuta.

¿Y no es lento interpretar instrucciones de una máquina imaginaria? Al principio lo era. Las JVM modernas, como HotSpot, la del JDK que usamos, empiezan interpretando el bytecode, pero van midiendo qué métodos se ejecutan mucho y los compilan a código máquina mientras el programa corre. Ese compilador se llama JIT (just-in-time). Un programa que corre varios segundos termina ejecutando casi todo su tiempo en código máquina optimizado para el procesador exacto donde está.

En Java. La última línea de java -version lo dice:

$ java -version
openjdk version "25.0.5" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8-LTS, mixed mode, sharing)

Mixed mode significa justamente eso: intérprete más JIT. Temurin es el nombre de la distribución del JDK (el código es el de OpenJDK).

Se puede mirar el bytecode. javap -c desensambla un .class, igual que objdump -d desensambla un ejecutable de C:

$ javac -d build Hello.java
$ javap -c build/Hello.class
Compiled from "Hello.java"
final class Hello {
  Hello();
    Code:
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return

  void main();
    Code:
         0: ldc           #7                  // String Hola, Java
         2: invokestatic  #9                  // Method java/lang/IO.println:(Ljava/lang/Object;)V
         5: return
}

No hace falta entender cada instrucción. Fíjense en dos cosas: ldc carga la cadena "Hola, Java" e invokestatic llama a IO.println. Y aunque nuestro archivo no decía class en ningún lado, el compilador creó una clase llamada Hello. En Java todo el código vive dentro de una clase; la siguiente sección explica de dónde salió esta.

Tres herramientas: javac, java y jshell

El JDK (Java Development Kit) trae muchas herramientas. Esta semana usamos tres:

Herramienta Qué hace Equivalente en C
javac Compila .java a .class (bytecode) gcc -c
java Arranca la JVM y ejecuta una clase ejecutar ./programa
jshell Evalúa código Java línea por línea no hay; se parece al intérprete de Python

El archivo más corto que podemos escribir es Hello.java:

void main() {
    IO.println("Hola, Java");
}

Hay dos maneras de ejecutarlo. La de dos pasos, como en C, compila primero y ejecuta después; -d build le dice a javac dónde dejar los .class, y -cp build (class path) le dice a java dónde buscarlos:

$ javac -d build Hello.java
$ ls build
Hello.class
$ java -cp build Hello
Hola, Java

Fíjense en que a java se le da el nombre de la clase (Hello), no el del archivo. La otra manera es pasarle a java el archivo fuente directamente: la JVM lo compila en memoria y lo ejecuta, sin dejar ningún .class. Es cómoda para programas de un archivo:

$ java Hello.java
Hola, Java

jshell sirve para probar una idea sin escribir un programa completo. Cada línea se evalúa al momento y jshell muestra el valor que quedó en la variable:

jshell> int x = 7 / 2
x ==> 3

jshell> double y = 7 / 2.0
y ==> 3.5

jshell> String s = "Lima"
s ==> "Lima"

jshell> int len = s.length()
len ==> 4

Igual que en C, la división entre dos enteros descarta los decimales. Para salir de jshell se escribe /exit.

Archivos fuente compactos

En C, un programa empieza en main. En Java también, pero hasta hace poco el programa más corto necesitaba toda esta ceremonia:

public class Hello {
    public static void main(String[] args) {
        System.out.println("Hola, Java");
    }
}

public, class, static y String[] args son ideas que este curso explica en las próximas semanas, y obligar a escribirlas el primer día confunde más de lo que ayuda. Java 25 agregó los archivos fuente compactos (compact source files, JEP 512): un archivo puede contener directamente métodos y campos, sin declarar una clase, y main puede ser simplemente void main(). El compilador envuelve todo en una clase implícita con el nombre del archivo; es la final class Hello que vimos en el bytecode. Además, IO.println imprime en la salida estándar (como printf con \n al final) e IO.readln lee una línea del teclado, y en un archivo compacto están disponibles sin import todas las clases de uso común de la biblioteca estándar.

Idea clave. Un archivo compacto es Java normal con menos ceremonia. Lo usaremos en las semanas 1 y 2 para programas cortos y demostraciones. Las clases de verdad, como TransitCard más abajo, van en su propio archivo con class, y desde la semana 3 todo el código va así.

Tipos primitivos

Java tiene ocho tipos primitivos, casi los mismos de C, con una diferencia importante: su tamaño está fijado por el lenguaje y no depende de la máquina. En C, un int puede tener 16, 32 o 64 bits según el compilador; en Java siempre tiene 32.

Tipo Tamaño Valores
byte 8 bits −128 a 127
short 16 bits −32 768 a 32 767
int 32 bits unos ±2 100 millones
long 64 bits unos ±9,2 × 10¹⁸
float 32 bits punto flotante IEEE 754, simple precisión
double 64 bits punto flotante IEEE 754, doble precisión
char 16 bits una unidad de código UTF-16
boolean — true o false

No hay unsigned. Y boolean es un tipo aparte: no es un número, así que una condición tiene que ser de tipo boolean.

Un char no es exactamente “un carácter”. Es una unidad de código UTF-16 de 16 bits. Las letras del español, con ñ y tildes, caben en un char. Pero Unicode tiene más de un millón de puntos de código (code points), y los que no caben en 16 bits, como la mayoría de los emojis, ocupan dos char. Además, lo que en pantalla se ve como una sola letra puede estar formado por varios puntos de código: una e seguida de un acento combinante, por ejemplo. Para el texto de este curso un char por letra basta; conviene saber que no es la regla general.

Error común. Escribir una condición al estilo C:

int n = 5;
if (n) {
    IO.println("cinco");
}
$ javac -Xlint:all Check.java
Check.java:3: error: incompatible types: int cannot be converted to boolean
    if (n) {
        ^
1 error

En C, if (n) significa “si n no es cero”. En Java hay que decirlo: if (n != 0). Es una de las reglas que evita errores como if (x = 0), que en C compila y casi nunca es lo que se quería.

FromC.java muestra el integer overflow: cuando un int pasa de su máximo, da la vuelta (wraps around) al mínimo, y siempre al mismo valor, porque el tamaño es fijo. En C, el overflow de un entero con signo es comportamiento indefinido (undefined behavior): el compilador puede asumir que nunca pasa.

int n = 2_147_483_647;          // int siempre tiene 32 bits
long big = n + 1L;              // long siempre tiene 64 bits
IO.println("n + 1 como int:  " + (n + 1));
IO.println("n + 1 como long: " + big);
$ java -cp build FromC
n + 1 como int:  -2147483648
n + 1 como long: 2147483648
...

(Los guiones bajos dentro de un número solo sirven para leerlo mejor.)

Referencias: String y arreglos

Todo lo que no es un tipo primitivo es un objeto, y a los objetos se llega a través de referencias (references). Una variable int guarda un número. Una variable String no guarda las letras: guarda una referencia, algo así como una flecha, a un objeto String que vive en otra parte de la memoria, el heap. Lo mismo pasa con los arreglos y con cualquier clase que escribamos.

A la izquierda, cuatro variables: int n guarda 42 y double p guarda 3.5 dentro de la propia variable; String s e int[] a guardan un punto del que sale una flecha. A la derecha, en el heap, la flecha de s llega a un objeto String "Lima" y la de a a un objeto int[] con length = 3 y tres casillas en 0A la izquierda, cuatro variables: int n guarda 42 y double p guarda 3.5 dentro de la propia variable; String s e int[] a guardan un punto del que sale una flecha. A la derecha, en el heap, la flecha de s llega a un objeto String "Lima" y la de a a un objeto int[] con length = 3 y tres casillas en 0
Una variable primitiva guarda su valor. Una variable de tipo referencia guarda una flecha a un objeto en el heap.

Si vienen de C, una referencia se parece a un puntero, con tres diferencias que importan mucho: no se puede hacer aritmética con ella (no existe p + 1), no se puede convertir en un número ni un número en referencia, y no puede apuntar a cualquier lugar: solo a un objeto del tipo correcto, o a ninguno. La semana 2 estudiamos las referencias en detalle.

Dos tipos de referencia aparecen en todo programa:

  • String es una clase de la biblioteca estándar. Un String no es un char* terminado en '\0': es un objeto que sabe su longitud y ofrece métodos, como length(), toUpperCase() o contains(...). Y no se puede modificar: toUpperCase() no cambia el original, devuelve otro String.
  • Los arreglos son objetos que conocen su longitud, en el campo length. Se crean con new, y sus elementos empiezan en cero (o false, o null), nunca con basura.
int[] marks = new int[4];       // los elementos empiezan en 0
marks[0] = 15;
marks[3] = 18;
int sum = 0;
for (int i = 0; i < marks.length; i++) {
    sum += marks[i];
}

En C, leer marks[4] en un arreglo de 4 elementos es comportamiento indefinido: el programa lee lo que haya en esa memoria, y a veces parece funcionar. En Java cada acceso se verifica:

jshell> int[] a = new int[3]
a ==> int[3] { 0, 0, 0 }

jshell> int n = a.length
n ==> 3

jshell> a[3] = 1
|  Exception java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
|        at (#7:1)

El programa se detiene con una excepción que dice exactamente qué pasó: el índice 3 está fuera de un arreglo de longitud 3. Las excepciones son el tema de la semana 9; por ahora, lean el mensaje: casi siempre dice dónde está el error.

Sin punteros ni free: el garbage collector

En C, la memoria que se pide con malloc se devuelve con free. Si se olvida el free, el programa pierde memoria (un memory leak); si se hace free dos veces, o se usa un puntero después del free, el programa puede hacer cualquier cosa.

En Java se pide memoria con new, y no hay free. La JVM tiene un garbage collector que, de vez en cuando, busca los objetos a los que ya no llega ninguna referencia del programa y recupera su memoria. Un objeto al que nadie puede llegar no le sirve a nadie, así que liberarlo es seguro. Con eso desaparecen de un golpe los memory leaks por olvidar un free, los free dobles y los dangling pointers (punteros a memoria ya liberada). Lo que el garbage collector no resuelve es un objeto que sigue alcanzable aunque el programa ya no lo necesite: ese no se libera nunca. Es la forma que toman los memory leaks en Java, y la semana 2 vuelve sobre ella.

En Java. El garbage collector no es gratis: trabaja mientras el programa corre y consume algo de CPU. Los garbage collectors modernos de HotSpot, como G1 (el que la JVM elige en casi todas las computadoras actuales) y ZGC, hacen casi todo su trabajo en paralelo con el programa, con pausas muy cortas. Para este curso basta con saber que existe y que no hay que liberar nada a mano.

Idea clave. De C a Java: se compila a bytecode que ejecuta una JVM (con intérprete y JIT); los tipos primitivos tienen tamaño fijo; todo lo demás son objetos a los que se llega por referencias, sin aritmética de punteros; los arreglos conocen su longitud y verifican los índices; y la memoria la libera el garbage collector.

Objetos como estado y comportamiento

El problema de los datos sueltos

Pensemos en la tarjeta del transporte público de una ciudad: tiene un código y un saldo, se recarga y se usa para pagar el pasaje. En C la escribiríamos con un struct para los datos y funciones aparte que reciben un puntero al struct (transit_card.c):

struct card {
    char id[8];
    int balance;            /* saldo en céntimos */
};

void card_recharge(struct card *c, int amount) {
    c->balance += amount;
}

int card_pay(struct card *c, int fare) {
    if (c->balance < fare)
        return 0;           /* no alcanza el saldo */
    c->balance -= fare;
    return 1;
}

Guardamos el saldo en céntimos, como un entero, para no tener problemas de redondeo con el punto flotante: S/ 10.00 son 1000 céntimos.

Este código funciona, y es como se escriben muchos programas grandes en C. Pero tiene un problema que crece con el programa. Las reglas de la tarjeta (que el saldo no sea negativo, que un pasaje solo se cobre si alcanza) viven en card_pay, pero nada obliga a pasar por ahí. Cualquier función de cualquier archivo puede escribir directamente en el campo. El main de transit_card.c lo hace:

int main(void) {
    struct card c = { "A-001", 0 };
    card_recharge(&c, 1000);
    card_pay(&c, 250);
    printf("%s: %d céntimos\n", c.id, c.balance);

    c.balance = -500;       /* nada lo impide */
    printf("%s: %d céntimos\n", c.id, c.balance);
    return 0;
}

El contenedor del curso trae gcc, así que make también compila la versión en C:

$ make
...
gcc -Wall -Wextra -Werror -O1 -o build/transit_card transit_card.c
$ ./build/transit_card
A-001: 750 céntimos
A-001: -500 céntimos

El compilador no se queja y el programa tampoco: la tarjeta quedó con saldo negativo.

En un programa de diez líneas eso se ve. En uno de cincuenta mil, con diez personas trabajando, alguien lo va a hacer, por error o por apuro, y cuando el saldo aparezca negativo habrá que revisar todas las funciones que tocan balance para saber cuál fue. Además, nada dice qué funciones “pertenecen” a la tarjeta: card_recharge y card_pay están juntas porque las pusimos juntas y les pusimos el mismo prefijo, no porque el lenguaje las relacione con struct card.

A la izquierda, al estilo C: card_recharge() y card_pay() apuntan a un struct card con campos id y balance, y desde abajo "cualquier otra función" también llega directo a balance con c.balance = -500. A la derecha, como objeto: un objeto TransitCard con su estado (id y balance) adentro; los mensajes de afuera llegan a los métodos recharge(), pay() y balance(), y solo estos llegan al estado. Un acceso directo alice.balance = -500 está tachado: no compilaA la izquierda, al estilo C: card_recharge() y card_pay() apuntan a un struct card con campos id y balance, y desde abajo "cualquier otra función" también llega directo a balance con c.balance = -500. A la derecha, como objeto: un objeto TransitCard con su estado (id y balance) adentro; los mensajes de afuera llegan a los métodos recharge(), pay() y balance(), y solo estos llegan al estado. Un acceso directo alice.balance = -500 está tachado: no compila
Al estilo C, los datos están a la vista de todo el programa. En un objeto, el estado y las operaciones van juntos, y los demás solo pueden enviar mensajes.

El mismo problema aparece en Java si escribimos al estilo C. StructStyle.java usa una clase solo como registro de datos, con funciones aparte:

class CardData {
    String id;
    int balance;                    // saldo en céntimos
}

void recharge(CardData card, int amount) {
    card.balance += amount;
}
$ java -cp build StructStyle
A-001: 750 céntimos
A-001: -500 céntimos

La segunda línea es la tarjeta rota: el main le asignó −500 directamente. Usar Java no basta; hay que organizar el programa de otra forma.

La idea: juntar estado y comportamiento

Un objeto (object) junta dos cosas:

  • Estado (state): los datos que recuerda. La tarjeta recuerda su código y su saldo. En Java el estado se guarda en campos (fields), también llamados atributos.
  • Comportamiento (behavior): lo que sabe hacer. La tarjeta sabe recargarse, cobrar un pasaje y decir su saldo. En Java el comportamiento se escribe en métodos (methods).

El resto del programa no toca el estado: le envía un mensaje al objeto (“recárgate con 1000”, “cobra 250”) y el objeto responde ejecutando el método que corresponde. Es lo que en C hacíamos con card_pay(&c, 250), escrito al revés: primero a quién, después qué: alice.pay(250).

Una clase (class) es la descripción de un tipo de objeto: qué campos tiene cada objeto y qué métodos entiende. Cada objeto creado a partir de la clase es una instancia (instance) de ella. Si la clase es el plano, los objetos son las tarjetas concretas que andan en los bolsillos, cada una con su propio saldo.

Una primera clase

TransitCard.java:

public class TransitCard {
    private final String id;
    private int balance;            // saldo en céntimos

    /** Crea una tarjeta con el código dado y saldo cero. */
    public TransitCard(String id) {
        this.id = id;
        this.balance = 0;
    }

    /** Agrega saldo a la tarjeta. */
    public void recharge(int amount) {
        balance += amount;
    }

    /** Cobra un pasaje si alcanza el saldo; devuelve si se pudo cobrar. */
    public boolean pay(int fare) {
        if (balance < fare) {
            return false;
        }
        balance -= fare;
        return true;
    }

    public String id() {
        return id;
    }

    public int balance() {
        return balance;
    }
}

Por partes:

  • Los campos id y balance son el estado. Cada objeto TransitCard tiene los suyos. private significa que solo el código de esta clase puede usarlos; final dice que id no cambia después de crear la tarjeta. Los veremos con calma en la semana 3, al hablar de encapsulamiento (encapsulation).
  • El constructor (constructor) es el bloque que se llama igual que la clase y no tiene tipo de retorno. Se ejecuta una vez, al crear el objeto, y deja sus campos con valores válidos. this.id es el campo del objeto que se está creando; id, a secas, es el parámetro.
  • Los métodos recharge, pay, id y balance son el comportamiento. Dentro de un método, balance se refiere al campo del objeto que recibió el mensaje. Por eso pay no necesita un parámetro struct card *c: el objeto ya sabe cuál es.
  • Los métodos id() y balance() solo devuelven un campo. Así se puede leer el saldo sin poder cambiarlo.

Idea clave. Las reglas de la tarjeta están ahora en un solo lugar: la clase. Como balance es private, la única forma de cambiar el saldo es enviar recharge o pay. Si un día aparece un saldo negativo, el error está en esos métodos y en ningún otro sitio.

new, paso a paso

Un objeto se crea con new:

TransitCard alice = new TransitCard("A-001");

Esa línea hace tres cosas, en orden:

  1. new reserva el objeto en el heap, con espacio para todos sus campos, y los deja en su valor por defecto: 0 para los números, false para boolean y null (ninguna referencia) para las referencias.
  2. Se ejecuta el constructor, que inicializa los campos: id pasa a ser "A-001" y balance, 0.
  3. new devuelve una referencia al objeto, y esa referencia se guarda en la variable alice.
Animación: arriba, el código TransitCard alice = new TransitCard("A-001"); seguido de alice.recharge(1000);. Paso 1: aparece en el heap un objeto TransitCard con id = null y balance = 0. Paso 2: el constructor pone id = "A-001". Paso 3: la variable alice recibe una flecha al objeto. Paso 4: el mensaje recharge(1000) llega al objeto y balance pasa a 1000Animación: arriba, el código TransitCard alice = new TransitCard("A-001"); seguido de alice.recharge(1000);. Paso 1: aparece en el heap un objeto TransitCard con id = null y balance = 0. Paso 2: el constructor pone id = "A-001". Paso 3: la variable alice recibe una flecha al objeto. Paso 4: el mensaje recharge(1000) llega al objeto y balance pasa a 1000
Qué hace new: reservar, construir y devolver la referencia. Después, cada mensaje cambia el estado del objeto. (El campo id también es una referencia, a un objeto String; aquí se muestra su texto para simplificar.)

Después, alice.recharge(1000) es un mensaje: el objeto al que apunta alice ejecuta recharge y su balance pasa a 1000.

Error común. Declarar una variable no crea un objeto. TransitCard alice; solo reserva una variable capaz de guardar una referencia; el objeto aparece recién con new. La semana 2 vemos qué pasa si se usa una variable que no apunta a ningún objeto.

Mensajes, estado e identidad

CardDemo.java crea dos tarjetas y les envía mensajes:

TransitCard alice = new TransitCard("A-001");
TransitCard bob = new TransitCard("A-002");

alice.recharge(1000);
boolean paid = alice.pay(250);
IO.println(alice.id() + ": pagó " + paid + ", saldo " + alice.balance());

// bob tiene su propio estado: el pasaje de alice no lo toca.
paid = bob.pay(250);
IO.println(bob.id() + ": pagó " + paid + ", saldo " + bob.balance());

// Identidad: dos new crean dos objetos distintos.
TransitCard same = alice;
IO.println("alice == bob: " + (alice == bob));
IO.println("alice == same: " + (alice == same));
$ java -cp build CardDemo
A-001: pagó true, saldo 750
A-002: pagó false, saldo 0
alice == bob: false
alice == same: true

Tres ideas salen de esta salida:

  • Cada objeto tiene su propio estado. El mismo mensaje, pay(250), tiene resultados distintos: la tarjeta de Alice tenía saldo y la de Bob no. El método es uno solo, escrito una vez en la clase; lo que cambia es el estado del objeto que lo recibe.
  • El estado cambia con los mensajes. Después de recharge(1000) y pay(250), el saldo de alice es 750. Un objeto recuerda lo que le pasó.
  • Cada objeto tiene identidad (identity). Cada new crea un objeto distinto, aunque sea de la misma clase y aunque sus campos tuvieran los mismos valores. Con referencias, == pregunta si las dos flechas llegan al mismo objeto: alice == bob es false; alice == same es true, porque same = alice copió la flecha, no el objeto.

La diferencia entre que dos objetos sean el mismo (identidad) y que se parezcan (igualdad) es uno de los temas de la semana 2.

Ejemplo resuelto: la tarjeta en jshell

jshell puede cargar una clase con /open y probarla de forma interactiva. Dentro de code/week-01:

jshell> /open TransitCard.java

jshell> TransitCard c = new TransitCard("A-001")
c ==> TransitCard@31221be2

jshell> c.recharge(500)

jshell> int b = c.balance()
b ==> 500

jshell> c.balance = 99
|  Error:
|  balance has private access in TransitCard
|  c.balance = 99
|  ^-------^

Línea por línea:

  • /open compila la clase dentro de jshell.
  • Al crear la tarjeta, jshell muestra algo como TransitCard@31221be2: el nombre de la clase y un número que identifica al objeto. Ese número cambia en cada ejecución; en la semana 5 veremos cómo hacer que un objeto se imprima de forma más útil.
  • c.recharge(500) no devuelve nada (void), así que jshell no muestra ningún valor.
  • c.balance() con paréntesis es el método, y devuelve 500.
  • c.balance = 99, sin paréntesis, intenta tocar el campo directamente, y el compilador lo rechaza porque es private. Es exactamente la línea que en la versión al estilo C rompía la tarjeta. Ahora el error aparece al compilar, antes de que el programa llegue a ejecutarse.

El código de la semana y make test

Cada semana tiene su carpeta code/week-NN/ con un Makefile. Dentro del contenedor del curso:

$ cd code/week-01
$ make test
mkdir -p build
javac -Xlint:all -Werror -cp /opt/junit/junit.jar -d build CardDemo.java FromC.java Hello.java StructStyle.java TransitCard.java TransitCardTest.java
touch build/.stamp
mkdir -p build
gcc -Wall -Wextra -Werror -O1 -o build/transit_card transit_card.c
== transit_card (C)
A-001: 750 céntimos
A-001: -500 céntimos
== Hello
Hola, Java
...
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│  └─ TransitCardTest ✔
│     ├─ payWithEnoughBalance() ✔
│     ├─ newCardStartsEmpty() ✔
│     └─ payWithoutBalanceChangesNothing() ✔
└─ JUnit Vintage ✔
...
[         3 tests successful      ]
[         0 tests failed          ]

make compila todo a build/: el código Java con javac -Xlint:all -Werror y la versión en C con gcc -Wall -Wextra -Werror. Cualquier advertencia del compilador detiene la compilación, así que el código del curso compila sin warnings. make test ejecuta las demostraciones y luego las pruebas de TransitCardTest.java con JUnit, el framework de unit tests que usaremos todo el semestre. Por ahora basta con ver que corre y que las tres pruebas pasan; en la semana 3 aprenderán a escribirlas. make clean borra build/.

Resumen

  • Java compila a bytecode (.class), que la JVM de cada plataforma ejecuta interpretándolo y compilando con el JIT lo que más se usa. El mismo .class corre en todas partes.
  • javac compila, java ejecuta (una clase o directamente un archivo fuente) y jshell evalúa código línea por línea.
  • Un archivo fuente compacto (Java 25) contiene métodos sin declarar una clase; void main() es su punto de entrada e IO.println imprime.
  • Los tipos primitivos tienen tamaño fijo; boolean no es un número. Todo lo demás son objetos, a los que se llega por referencias, sin aritmética de punteros.
  • String es un objeto inmutable con métodos; los arreglos conocen su length y verifican cada índice.
  • No hay free: el garbage collector libera los objetos a los que ya no llega ninguna referencia. Los que siguen alcanzables no se liberan, aunque ya no sirvan.
  • Datos sueltos más funciones aparte dejan las reglas sin dueño: cualquier parte del programa puede romper los datos.
  • Un objeto junta estado (campos) y comportamiento (métodos); los demás le envían mensajes. Una clase describe sus objetos; cada uno es una instancia.
  • new reserva el objeto, ejecuta el constructor y devuelve una referencia. Cada objeto tiene su propio estado y su propia identidad.

Ejercicios

  1. Expliquen con sus palabras por qué el mismo Hello.class corre en una laptop con Linux y en otra con macOS y procesador ARM, mientras que un ejecutable compilado con gcc no. ¿Qué programa sí tiene que estar compilado para cada plataforma?

  2. Compilen Hello.java con javac -d build y desensámblenlo con javap -c build/Hello.class. Cambien el texto que se imprime y agreguen una segunda línea con IO.println. ¿Qué instrucciones nuevas aparecen en el bytecode?

  3. En jshell, evalúen int big = 2_147_483_647 + 1, long ok = 2_147_483_647L + 1, int r = 7 % 2, int c = 'a' + 1 y char d = (char) ('a' + 1). Antes de cada una, escriban qué esperan ver. Expliquen las que los sorprendieron.

  4. En jshell, creen int[] a = new int[3] y prueben a[-1] = 5. Luego escriban en el contenedor un programa en C equivalente, bounds.c, que declare int a[3] = {0, 0, 0};, haga a[-1] = 5; e imprima los tres elementos. Compílenlo con gcc -Wall -Wextra -o bounds bounds.c y ejecútenlo varias veces. ¿Qué dice el compilador? ¿Qué pasa al ejecutar? ¿Cuál de los dos comportamientos prefieren cuando están buscando un error, y por qué?

  5. Escriban un archivo compacto Initials.java que guarde un nombre completo en un String e imprima sus iniciales, usando length() y charAt(i). Ejecútenlo de las dos maneras: con java Initials.java y compilando con javac primero.

  6. Sin ejecutarlo, digan qué imprime este fragmento. Después compruébenlo en jshell (con /open TransitCard.java) y, si fallaron, expliquen en qué se equivocaron:

    TransitCard x = new TransitCard("B-1");
    TransitCard y = new TransitCard("B-1");
    TransitCard z = x;
    z.recharge(500);
    IO.println(x.balance() + " " + y.balance());
    IO.println((x == y) + " " + (x == z));
  7. Agreguen a TransitCard un campo que cuente cuántos pasajes se pagaron con éxito y un método trips() que lo devuelva. ¿Qué métodos tienen que cambiar? Modifiquen CardDemo para mostrarlo y verifiquen que make test sigue pasando.

  8. En StructStyle.java, cualquier parte del programa puede poner un saldo negativo. Con la clase TransitCard que tienen, ¿todavía hay alguna forma de dejar el saldo negativo usando solo sus métodos públicos? Si la hay, muéstrenla con un ejemplo. (No la arreglen todavía: es el tema de la semana 3.)

  9. Elijan un objeto de la vida real que no sea una tarjeta (un ascensor, un semáforo, una cuenta de un servicio de streaming…). Escriban qué campos tendría su estado y qué mensajes entendería. Luego escríbanlo dos veces: al estilo C (un registro con funciones aparte) y como clase con constructor y métodos.

  10. Supongan que en CardDemo agregan, al final, la línea alice = new TransitCard("A-003");. ¿Qué pasa con el objeto de la tarjeta A-001? ¿Puede el programa volver a usarlo? ¿Quién libera su memoria y cuándo?

  11. Lean la sección 1.5 de Eck. Él describe un objeto como un módulo que guarda datos y las subrutinas que los manipulan. Comparen esa descripción con la idea de “estado más comportamiento” de este capítulo. ¿Qué gana el programa al juntar las dos cosas?

  12. En TransitCardTest.java, cambien el valor esperado de una prueba para que sea incorrecto (por ejemplo, 700 en lugar de 750) y ejecuten make test. Lean el mensaje de JUnit: ¿qué prueba falló, qué esperaba y qué obtuvo? Después devuelvan el valor correcto.