Ir al contenido

Rust para desarrolladores de Java: guía práctica

Si llevas años con Java, ya tienes casi todo lo que necesitas para aprender Rust. Piensas en tipos. Conoces los genéricos, las interfaces, las lambdas y las cadenas de streams. Rust tiene todo eso: los traits se parecen mucho a las interfaces con métodos por defecto, los iteradores se leen como streams, y Cargo hace el trabajo de Maven o Gradle, pero con mucha menos configuración.

Lo nuevo es todo lo que antes hacía la JVM por ti. Rust no tiene recolector de basura (garbage collector) ni máquina virtual. Tu código se compila a un binario nativo, y el compilador decide cuándo se libera la memoria comprobando el ownership (propiedad) y el borrowing (préstamo) antes de que el programa se ejecute. Rust tampoco tiene null (usas Option), ni excepciones (usas Result), ni herencia de clases. Esos cuatro cambios son los que más tiempo de aprendizaje te van a llevar.

¿Por qué se animan con Rust los desarrolladores de Java? Normalmente por uno de estos motivos: servicios que necesitan un uso de memoria bajo y estable, sin pausas del GC; herramientas de línea de comandos y funciones serverless que deben arrancar al instante, sin JVM; una parte crítica de una aplicación Java que quieres pasar a código nativo (Rust se puede llamar desde Java mediante JNI o la nueva Foreign Function & Memory API); o simplemente la curiosidad por saber por qué Rust no deja de encabezar las encuestas del “lenguaje más admirado”. Esta página traduce a Rust lo que ya conoces de Java, para que te centres en lo que de verdad es distinto.

Si prefieres una comparación general con C++, Go y Python, consulta Rust vs C++, Go y Python. Si también conoces .NET, hay una página aparte: Rust para desarrolladores de C#.

Usa esta tabla como un diccionario de bolsillo. La última columna enlaza con la lección de este sitio que explica la parte de Rust.

Java Rust Para saber más
class con campos y métodos struct para los datos + un bloque impl para los métodos Structs
Constructores y new Una función normal, llamada new por convención, que devuelve Self Structs
interface trait Traits
Métodos default en una interfaz Métodos por defecto en un trait (funcionan igual) Traits
abstract class, extends, super No hay herencia. Usa traits con métodos por defecto para el comportamiento compartido, y composición (un struct dentro de otro) para los datos compartidos Traits, Patrones de diseño
List<Shape> con clases distintas Vec<Box<dyn Shape>> (un trait object) Trait objects
sealed interface + records + switch con patrones (Java 17/21) Un enum cuyas variantes llevan datos, más match Enums, Match
enum con campos y constructores enum donde cada variante puede guardar datos distintos Enums
record Point(int x, int y) #[derive(Debug, Clone, PartialEq)] struct Point { x: i32, y: i32 } Structs, Traits comunes
toString(), equals(), hashCode(), compareTo() Display / Debug, PartialEq / Eq, Hash, PartialOrd / Ord (normalmente con derive) Traits comunes
== (mismo objeto) frente a .equals() (mismo valor) == siempre compara valores. No existe la trampa de comparar referencias Operadores
null y Optional<T> Option<T>: o bien Some(value) o bien None Option
orElse, orElseGet, orElseThrow, map, flatMap, ifPresent unwrap_or, unwrap_or_else, ok_or / expect, map, and_then, if let Some(...) Option, If Let / While Let
Excepciones comprobadas y throws Result<T, E> en el tipo de retorno, más el operador ? Result, Errores personalizados
Excepciones no comprobadas para bugs de verdad (IllegalStateException) panic! (detiene el hilo) Panic
Encadenar excepciones (new ConfigException(msg, cause)) map_err, o #[source] de thiserror y .context(...) de anyhow anyhow y thiserror
try-with-resources y AutoCloseable Drop, que se ejecuta automáticamente cuando el dueño sale del ámbito Ámbito, Punteros inteligentes
Recolector de basura Ownership y borrowing, comprobados al compilar Ownership, Borrowing
Variables locales final Un let normal (inmutable por defecto); escribe let mut para permitir cambios Variables
Constantes static final const Variables
Campos static mutables, singletons static con LazyLock u OnceLock, envuelto en un Mutex si tiene que cambiar Estado compartido
Genéricos con <T extends Comparable<T>> Genéricos con trait bounds (restricciones): T: Ord o where T: Ord Genéricos
Borrado de tipos, List<Integer> en lugar de List<int> Monomorfización: el código genérico se compila para cada tipo, y Vec<i32> guarda números sin envolver Genéricos, Genéricos avanzados
Lambdas, Function<T, R>, Supplier<T>, Consumer<T> Closures, descritas por los traits Fn, FnMut, FnOnce Closures, Fn, FnMut y FnOnce
Streams: filter, map, reduce, collect Adaptadores de iteradores: filter, map, fold, sum, collect Iteradores
Iterable<T> e Iterator<T> IntoIterator e Iterator (un solo método: next, que devuelve un Option) Iteradores personalizados
parallelStream() par_iter() del crate rayon Concurrencia
Thread, ExecutorService std::thread::spawn y std::thread::scope; rayon o tokio para pools Concurrencia
synchronized, ReentrantLock Mutex<T>, que envuelve los datos que protege Estado compartido
AtomicInteger, AtomicLong AtomicI32, AtomicI64, AtomicUsize Estado compartido
BlockingQueue entre hilos Canales (std::sync::mpsc) Canales
CompletableFuture, hilos virtuales async fn y .await, ejecutados por un runtime como tokio Async/Await
String (inmutable) y StringBuilder &str (una vista prestada de un texto) y String (con dueño, puede crecer) Strings
char (una unidad UTF-16) char (cualquier carácter Unicode, 4 bytes); los strings son UTF-8 Strings
int, long, double y el Integer envuelto i32, i64, f64, además de los sin signo de u8 a u128. Nunca se envuelven Tipos de datos
Desbordamiento de enteros silencioso Un panic en las compilaciones debug; usa checked_add, wrapping_add o saturating_add para elegir Desbordamiento de enteros
Casts como (int) x as para números, From / TryFrom para conversiones seguras Conversión de tipos, Conversiones
BigDecimal, BigInteger Los crates rust_decimal y num-bigint Tipos de datos
Paquetes e import Módulos (mod) y use Módulos
public, private, protected, visibilidad de paquete Privado por defecto; pub y pub(crate). No existe protected Módulos
Un JAR / artefacto de Maven Un crate (una biblioteca o un binario) Cargo y crates
Anotaciones (@Override, @Data de Lombok) Atributos como #[derive(Debug)] y #[test] Macros
Reflexión, procesar anotaciones en tiempo de ejecución Casi nada en tiempo de ejecución. Las macros generan código al compilar Macros procedurales
Sobrecarga de métodos No hay sobrecarga. Usa nombres distintos o un parámetro genérico Funciones
Comprobaciones con instanceof y downcasts match sobre un enum (el downcasting con Any existe, pero es poco común) Coincidencia de patrones
JNI, Foreign Function & Memory API Funciones extern "C" FFI: llamar a C

El Java Collections Framework encaja muy bien con std::collections. La mayor diferencia: las colecciones de Rust nunca devuelven null. Una búsqueda te da un Option.

Java Rust Para saber más
ArrayList<T> Vec<T> Vectores
int[], String[] [T; N] cuando la longitud se conoce al compilar; si no, Vec<T>. Cualquiera de los dos se pasa como slice &[T] Arrays, Slices
List.of(...) (no modificable) Un array, o un Vec que solo compartes como &Vec<T> / &[T] (nadie puede modificarlo a través de una referencia compartida) Borrowing
HashMap<K, V> HashMap<K, V> (get devuelve Option<&V>) HashMap
map.merge(word, 1, Integer::sum) *map.entry(word).or_insert(0) += 1 HashMap
TreeMap<K, V> BTreeMap<K, V> (las claves se mantienen ordenadas) HashMap
LinkedHashMap<K, V> (orden de inserción) IndexMap del crate indexmap
HashSet<T>, TreeSet<T> HashSet<T>, BTreeSet<T> HashSet
ArrayDeque<T> VecDeque<T> VecDeque y BinaryHeap
Stack<T>, o un Deque usado como pila Vec<T> con push / pop Vectores
PriorityQueue<T> (primero el menor) BinaryHeap<T> (primero el mayor; envuelve los elementos en Reverse para que salga primero el menor) VecDeque y BinaryHeap
LinkedList<T> LinkedList<T> existe, pero Vec o VecDeque son casi siempre más rápidos VecDeque y BinaryHeap
ConcurrentHashMap<K, V> Mutex<HashMap<K, V>>, o el crate dashmap Estado compartido
list.sort(Comparator.comparing(...)) v.sort_by_key(...) o v.sort_by(...) Ordenar y buscar
list.removeIf(...) v.retain(...) (conserva los elementos que cumplen la condición) Vectores

Igual que el HashMap de Java, un HashMap de Rust no tiene un orden fijo, y el orden puede cambiar entre ejecuciones. Si necesitas una salida ordenada, usa un BTreeMap u ordena antes las claves.

La mayoría de los desarrolladores de Java se enamoran de Cargo enseguida. Hay una sola herramienta, un solo formato de configuración y nada de XML de plugins. La compilación, las dependencias, los tests, la documentación, el formato y los lints salen todos del mismo sitio.

Java Rust Notas
Maven / Gradle cargo Compila, ejecuta, prueba, añade dependencias y publica. Consulta Cargo y crates
pom.xml / build.gradle.kts Cargo.toml Corto, y sin scripts de compilación en los proyectos normales
Bloqueo de dependencias de Gradle Cargo.lock Se crea y se actualiza solo
Compilación multimódulo Un workspace de Cargo Un Cargo.toml en la raíz enumera los crates miembros
Maven Central crates.io La documentación de la API de cada crate publicado está en docs.rs
Añadir una <dependency> cargo add serde
mvn archetype:generate / gradle init cargo new my_app (cargo new --lib para una biblioteca)
mvn compile / mvn test / mvn package cargo build / cargo test / cargo build --release Las compilaciones debug son mucho más lentas al ejecutarse; mide el rendimiento en release
java -jar app.jar ./target/release/app Un único binario nativo. No hay que instalar ninguna JVM en el servidor
Versiones del JDK, SDKMAN!, toolchains rustup y un archivo rust-toolchain.toml Consulta Herramientas de Rust
javac rustc Cargo lo llama por ti; casi nunca lo ejecutas tú
JShell El Rust Playground en tu navegador, o evcxr_repl
Checkstyle, SpotBugs, PMD, Error Prone cargo clippy Cientos de lints oficiales
google-java-format, Spotless cargo fmt Un único estilo estándar; nadie discute sobre las llaves
IntelliJ IDEA, Eclipse JetBrains RustRover, o VS Code + rust-analyzer rust-analyzer ofrece autocompletado, ir a la definición y errores en línea
Javadoc Comentarios de documentación /// en Markdown + cargo doc Los ejemplos de código de la documentación se ejecutan como tests. Consulta Documentación en Rust
JUnit 5 #[test] y cargo test, incluidos de serie (cargo nextest es un ejecutor más rápido muy popular) Consulta Pruebas en Rust
@ParameterizedTest rstest
Mockito mockall, o implementaciones falsas escritas a mano que implementan tu trait Consulta Pruebas avanzadas
AssertJ / Hamcrest assert!, assert_eq! y pretty_assertions para diferencias legibles
WireMock, Testcontainers wiremock, testcontainers Las mismas ideas, en versión Rust
JaCoCo cargo llvm-cov o cargo tarpaulin
JMH criterion Consulta Rendimiento en Rust
JFR, async-profiler, VisualVM cargo flamegraph (se instala con cargo install flamegraph), perf No hay GC ni JIT que ajustar
GraalVM native-image No hace falta Toda compilación de Rust ya es código nativo

Aquí tienes un Cargo.toml pequeño. Compáralo con el pom.xml que necesitarías para las mismas tres dependencias:

[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Bibliotecas y frameworks: “¿Qué usamos en lugar de…?”

Sección titulada «Bibliotecas y frameworks: “¿Qué usamos en lugar de…?”»

Esta es la primera pregunta de cualquier equipo de Java: “Trabajamos con Spring Boot e Hibernate. ¿Cuál es la versión de Rust?” Rust no tiene un Spring, un único framework que lo traiga todo. En su lugar, combinas unos cuantos crates con un objetivo concreto. Aquí tienes los equivalentes más cercanos.

Java Rust Notas
Spring Boot (Spring MVC / WebFlux) axum (construimos una API en Proyecto: API web con Axum), actix-web, Rocket axum y actix-web se usan mucho en producción
@RestController + @GetMapping Rutas de axum que apuntan a simples handlers async fn Sin anotaciones ni escaneo del classpath; las rutas se conectan en el código
Filtros de servlets, interceptores de Spring Capas de tower y el crate tower-http (CORS, compresión, trazas, timeouts) axum usa tower directamente
Inyección de dependencias de Spring (@Autowired) State compartido en axum, constructores normales y traits Mira la fila de DI más abajo
springdoc-openapi utoipa
Thymeleaf askama (plantillas comprobadas al compilar) o tera
Spring WebSocket WebSockets en axum (la feature ws), o tokio-tungstenite
Spring for GraphQL async-graphql
gRPC Java tonic (con prost para protobuf)
java.net.http.HttpClient, OkHttp, WebClient reqwest Asíncrono por defecto, con un cliente bloqueante opcional
Netty tokio + hyper
Spring Security (JWT, hash de contraseñas) jsonwebtoken, argon2 No hay un framework de seguridad todo en uno
Java Rust Notas
Hibernate / JPA / Spring Data JPA SeaORM: ORM asíncrono con entidades, relaciones y migraciones Es el que más se parece a JPA; está construido sobre sqlx
Hibernate, si buscas tipado estricto Diesel: ORM y constructor de consultas; tus consultas se comprueban por tipos al compilar Maduro (2.x), síncrono por defecto (existe diesel-async)
JDBC, JdbcTemplate, jOOQ, MyBatis sqlx: SQL normal, y la macro query! lo comprueba contra tu base de datos real al compilar Asíncrono, muy popular, no es un ORM
Flyway / Liquibase sqlx migrate (sqlx-cli), diesel migration o refinery
Jedis / Lettuce (Redis) redis
Clientes de Kafka, Spring Kafka rdkafka (construido sobre librdkafka)
Cliente Java de RabbitMQ lapin
AWS SDK for Java El AWS SDK for Rust oficial: aws-config, aws-sdk-s3, …

Si te gustan las entidades de JPA, prueba primero SeaORM. Si prefieres escribir SQL (los fans de jOOQ y MyBatis), prueba sqlx. Si quieres las garantías más fuertes al compilar, prueba Diesel. Bases de datos en Rust muestra los tres con código que funciona.

Java Rust Notas
Jackson / Gson serde + serde_json El estándar. También sirve para TOML, YAML y más. Consulta JSON con Serde
JAXB, Jackson XML quick-xml (con su feature serialize para serde)
SLF4J + Logback / Log4j 2 tracing (+ tracing-subscriber), o log + env_logger Consulta Logging en Rust
Lombok Derives incorporados (Debug, Clone, PartialEq, Default, …); bon o derive_builder para builders Nada de trucos con bytecode; todo son macros normales
MapStruct Implementaciones de From / Into Escritas a mano y comprobadas por el compilador. Consulta Conversiones
El contenedor IoC de Spring Normalmente nada: pasa las dependencias a los constructores y usa traits para lo que quieras cambiar en los tests Los proyectos de Rust rara vez usan un contenedor de DI
Bean Validation (@NotNull, @Size) validator o garde (validación basada en derive) @NotNull no hace falta: usa Option solo donde un valor puede faltar
Resilience4j backon, o el middleware de reintentos y timeouts de tower
Caffeine (caché) moka
Guava, Apache Commons Sobre todo la biblioteca estándar, más itertools
java.time chrono o jiff Consulta Fechas y horas
picocli clap Consulta Proyecto: CLI con clap
@Scheduled, Quartz tokio-cron-scheduler
Micrometer metrics, u opentelemetry
Jakarta Mail lettre

Escritorio, juegos, Android e interoperabilidad

Sección titulada «Escritorio, juegos, Android e interoperabilidad»
Java Rust Notas
JavaFX / Swing Tauri (interfaz web + backend en Rust), Slint, egui, Iced Tauri 2 y Slint 1.x son los más estables. Consulta Apps con interfaz gráfica
LibGDX, jMonkeyEngine Bevy, macroquad Bevy aún no llega a la 1.0 y cambia su API entre versiones. Consulta Desarrollo de videojuegos
Código de apps Android Mantén Kotlin o Java para la interfaz; pon la lógica compartida en una biblioteca de Rust compilada con el Android NDK uniffi, de Mozilla, genera los bindings para Kotlin
Llamar a código nativo desde Java Expón funciones extern "C" y llámalas con la FFM API (JDK 22+), o escribe funciones JNI con el crate jni Consulta FFI: llamar a C

Lado a lado: una clase abstracta se convierte en trait

Sección titulada «Lado a lado: una clase abstracta se convierte en trait»

Aquí tienes una jerarquía clásica de Java: una clase base abstracta con un método abstracto y un método compartido.

abstract class Employee {
protected final String name;
Employee(String name) { this.name = name; }
abstract int monthlyPay();
String payslip() { return name + " earns " + monthlyPay() + " a month"; }
}
class Salaried extends Employee {
private final int yearly;
Salaried(String name, int yearly) { super(name); this.yearly = yearly; }
@Override int monthlyPay() { return yearly / 12; }
}
class Contractor extends Employee {
private final int hours, rate;
Contractor(String name, int hours, int rate) { super(name); this.hours = hours; this.rate = rate; }
@Override int monthlyPay() { return hours * rate; }
}
List<Employee> staff = List.of(new Salaried("Ada", 60000), new Contractor("Linus", 120, 50));
for (Employee e : staff) System.out.println(e.payslip());

Rust no tiene extends. El método compartido pasa a un trait como método por defecto, y cada struct conserva sus propios datos:

trait Employee {
fn name(&self) -> &str;
fn monthly_pay(&self) -> u32;
// Un método por defecto: como el payslip() concreto de la clase abstracta.
fn payslip(&self) -> String {
format!("{} gana {} al mes", self.name(), self.monthly_pay())
}
}
struct Salaried {
name: String,
yearly: u32,
}
struct Contractor {
name: String,
hours: u32,
rate: u32,
}
impl Employee for Salaried {
fn name(&self) -> &str {
&self.name
}
fn monthly_pay(&self) -> u32 {
self.yearly / 12
}
}
impl Employee for Contractor {
fn name(&self) -> &str {
&self.name
}
fn monthly_pay(&self) -> u32 {
self.hours * self.rate
}
}
fn main() {
// Box<dyn Employee> hace el papel de "una referencia a algún Employee".
let staff: Vec<Box<dyn Employee>> = vec![
Box::new(Salaried { name: "Ada".to_string(), yearly: 60_000 }),
Box::new(Contractor { name: "Linus".to_string(), hours: 120, rate: 50 }),
];
for e in &staff {
println!("{}", e.payslip());
}
}

Salida:

Ada gana 5000 al mes
Linus gana 6000 al mes
▶ Pruébalo tú mismo

Un trait no puede tener campos, así que el protected name se convierte en un pequeño método name() que cada struct implementa. Al principio parece que escribes de más, pero elimina una categoría entera de problemas: no hay clases base frágiles, ni llamadas a super que olvidar, ni jerarquías profundas que recorrer. Si conoces los métodos default de las interfaces de Java 8, ya sabes cómo comparte comportamiento Rust. Y cuando el conjunto de tipos es fijo (como una sealed interface de Java 17), un enum con un match suele ser más sencillo que un trait.

Lado a lado: Optional se convierte en Option

Sección titulada «Lado a lado: Optional se convierte en Option»

El Optional de Java fue un gran avance, pero solo se usa para valores de retorno, y map.get() o cualquier campo pueden seguir siendo null:

static Optional<String> findEmail(int userId) {
return userId == 1 ? Optional.of("[email protected]") : Optional.empty();
}
for (int id : new int[] {1, 2}) {
System.out.println(findEmail(id).map(String::toUpperCase).orElse("no email on file"));
}
findEmail(1).ifPresent(email -> System.out.println("sending to " + email));

El Option de Rust tiene casi la misma API, pero se usa en todas partes donde un valor puede faltar, incluidas las búsquedas en colecciones. Y no hay ningún null escondido detrás:

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
for id in [1, 2] {
// map + orElseGet
let shown = find_email(id)
.map(|email| email.to_uppercase())
.unwrap_or_else(|| "no hay email registrado".to_string());
println!("{shown}");
}
// ifPresent
if let Some(email) = find_email(1) {
println!("enviando a {email}");
}
// flatMap se llama and_then
let domain = find_email(1).and_then(|email| email.split_once('@').map(|(_, d)| d.to_string()));
println!("{domain:?}");
}

Salida:

no hay email registrado
Some("example.com")
▶ Pruébalo tú mismo

El compilador no te deja llamar a .to_uppercase() sobre un Option<String> hasta que hayas tratado el None, así que la NullPointerException simplemente no existe. Además, Option no cuesta nada extra: un Option<&T> ocupa lo mismo que una referencia normal. Aprende los métodos auxiliares en la página de Option.

Lado a lado: las excepciones comprobadas se convierten en Result

Sección titulada «Lado a lado: las excepciones comprobadas se convierten en Result»

Las excepciones comprobadas partían de una buena idea: la firma te dice qué puede salir mal. Esta es una versión típica en Java:

class InsufficientFundsException extends Exception {
InsufficientFundsException(int needed) { super("need " + needed + " more"); }
}
static int withdraw(int balance, int amount) throws InsufficientFundsException {
if (amount > balance) throw new InsufficientFundsException(amount - balance);
return balance - amount;
}
static int pay(int balance, int amount) throws InsufficientFundsException {
int left = withdraw(balance, amount);
System.out.println("paid " + amount + ", " + left + " left");
return left;
}
try { pay(100, 80); pay(50, 80); }
catch (InsufficientFundsException e) { System.out.println("error: " + e.getMessage()); }

Rust se queda con esa buena idea y descarta el resto. El error forma parte del tipo de retorno (Result<u32, BankError>), y ? hace lo mismo que throws: “si esto falló, pasa el error a quien me llamó”. Los errores suelen ser un enum, así que un solo tipo puede enumerar todas las formas en que puede fallar una operación:

use std::fmt;
#[derive(Debug)]
enum BankError {
InvalidAmount,
InsufficientFunds { needed: u32 },
}
// Display hace el papel de getMessage().
impl fmt::Display for BankError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BankError::InvalidAmount => write!(f, "la cantidad debe ser mayor que cero"),
BankError::InsufficientFunds { needed } => write!(f, "faltan {needed}"),
}
}
}
fn withdraw(balance: u32, amount: u32) -> Result<u32, BankError> {
if amount == 0 {
return Err(BankError::InvalidAmount);
}
if amount > balance {
return Err(BankError::InsufficientFunds { needed: amount - balance });
}
Ok(balance - amount)
}
fn pay(balance: u32, amount: u32) -> Result<u32, BankError> {
let left = withdraw(balance, amount)?; // como llamar a un método que "lanza" excepciones
println!("pagado {amount}, quedan {left}");
Ok(left)
}
fn main() {
for (balance, amount) in [(100, 80), (50, 80), (100, 0)] {
if let Err(e) = pay(balance, amount) {
println!("error: {e}");
}
}
}

Salida:

pagado 80, quedan 20
error: faltan 30
error: la cantidad debe ser mayor que cero
▶ Pruébalo tú mismo

Fíjate en lo que ha desaparecido: no hay bloques try, ni desenrollado de la pila, ni excepciones envueltas en RuntimeException solo para que pasen a través de una lambda. Los errores son valores normales, así que funcionan sin problema dentro de closures y cadenas de iteradores. El ? es una marca visible en cada punto en el que la función puede terminar antes de tiempo. Escribir Display a mano cansa pronto; el crate thiserror lo genera por ti, y anyhow da a las aplicaciones un tipo de error para todo (consulta anyhow y thiserror). Para los bugs de verdad, los que señalarías con una excepción no comprobada, Rust tiene panic!.

Lado a lado: los streams se convierten en iteradores

Sección titulada «Lado a lado: los streams se convierten en iteradores»

Una cadena de streams con un collector groupingBy:

record Order(String customer, String city, int total) {}
List<Order> orders = List.of(
new Order("Ada", "London", 120), new Order("Grace", "New York", 80),
new Order("Linus", "Helsinki", 200), new Order("Ken", "New York", 150));
List<String> big = orders.stream()
.filter(o -> o.total() > 100)
.sorted(Comparator.comparingInt(Order::total).reversed())
.map(Order::customer)
.toList();
System.out.println(String.join(", ", big));
Map<String, Integer> byCity = orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.summingInt(Order::total)));
byCity.forEach((city, sum) -> System.out.println(city + ": " + sum));

En Rust, .iter() hace el papel de .stream(), y los adaptadores tienen nombres conocidos:

use std::cmp::Reverse;
use std::collections::BTreeMap;
struct Order {
customer: String,
city: String,
total: u32,
}
fn order(customer: &str, city: &str, total: u32) -> Order {
Order { customer: customer.to_string(), city: city.to_string(), total }
}
fn main() {
let orders = vec![
order("Ada", "London", 120),
order("Grace", "New York", 80),
order("Linus", "Helsinki", 200),
order("Ken", "New York", 150),
];
// filter + toList
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// sorted(...reversed()): se ordena en un Vec, no dentro del iterador.
big.sort_by_key(|o| Reverse(o.total));
// map
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// groupingBy(city, TreeMap::new, summingInt(total))
let mut by_city: BTreeMap<&str, u32> = BTreeMap::new();
for o in &orders {
*by_city.entry(o.city.as_str()).or_insert(0) += o.total;
}
for (city, sum) in &by_city {
println!("{city}: {sum}");
}
}

Salida:

Linus, Ken, Ada
Helsinki: 200
London: 120
New York: 230
▶ Pruébalo tú mismo

Igual que los streams, los iteradores de Rust son perezosos (lazy) y solo se ejecutan cuando algo los consume (collect, sum, un bucle for). A diferencia de los streams, no hay una clase Collectors: collect() mira el tipo que le pides (Vec, HashMap, String, …) y lo construye. Para agrupar, lo habitual es un bucle normal con la API entry; el crate itertools añade funciones auxiliares si echas de menos groupingBy. Y las cadenas de iteradores se compilan al mismo código máquina que un bucle escrito a mano, sin envolver ningún int por el camino. Tienes más en Iteradores y en Ordenar y buscar.

Lado a lado: borrado de tipos frente a monomorfización

Sección titulada «Lado a lado: borrado de tipos frente a monomorfización»

Los genéricos de Java solo existen al compilar. Al ejecutar, T se borra, así que no puedes escribir new T(), ni meter un int en una List sin envolverlo:

static <T> List<T> filled(int n) {
List<T> list = new ArrayList<>();
for (int i = 0; i < n; i++) list.add(new T()); // no compila: T está borrado
return list;
}
List<int> ids; // esto tampoco compila: necesitas List<Integer>

Rust compila una copia separada de cada función genérica para cada tipo con el que la usas (esto se llama monomorfización). Así, la función sabe exactamente qué es T, y puede llamar a T::default():

use std::mem::size_of;
// "T tiene que tener un valor por defecto y poder clonarse"
fn filled<T: Default + Clone>(n: usize) -> Vec<T> {
vec![T::default(); n]
}
fn main() {
let ids: Vec<i32> = filled(3);
let names: Vec<String> = filled(2);
let flags: Vec<bool> = filled(2);
println!("{ids:?} {names:?} {flags:?}");
// Sin envolver: un Vec<i32> guarda números normales de 4 bytes, uno junto a otro.
println!("un i32 ocupa {} bytes", size_of::<i32>());
println!("un bool ocupa {} byte", size_of::<bool>());
}

Salida:

[0, 0, 0] ["", ""] [false, false]
un i32 ocupa 4 bytes
un bool ocupa 1 byte
▶ Pruébalo tú mismo

El precio: más copias del código significan binarios más grandes y compilaciones más largas, pero cada llamada es directa y se puede integrar en línea (inlining). Cuando quieres una única copia compartida (como hace Java), usa un trait object como Box<dyn Trait>. Las restricciones después de los dos puntos (T: Default + Clone) son la versión de Rust de <T extends ...>. Consulta Genéricos y Genéricos avanzados.

Lado a lado: synchronized se convierte en Arc y Mutex

Sección titulada «Lado a lado: synchronized se convierte en Arc y Mutex»

Cuatro hilos incrementan un contador compartido. En Java, el lock vive en el objeto, y depende de ti acordarte de synchronized:

class Counter {
private int count = 0;
synchronized void increment() { count++; }
synchronized int get() { return count; }
}
Counter counter = new Counter();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 4; i++) {
Thread t = new Thread(() -> { for (int j = 0; j < 1000; j++) counter.increment(); });
t.start();
threads.add(t);
}
for (Thread t : threads) t.join();
System.out.println("count = " + counter.get());

En Rust, el Mutex es dueño del número. No hay forma de llegar a él sin bloquearlo primero. Arc (un puntero con conteo de referencias atómico) permite que varios hilos compartan el mutex:

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
// Cada hilo recibe su propio handle al mismo Mutex.
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
// lock() devuelve un guard; el lock se libera cuando se descarta el guard.
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("contador = {}", *counter.lock().unwrap());
}

Salida:

contador = 4000
▶ Pruébalo tú mismo

Esta es la parte que encanta a los desarrolladores de Java: si en Java olvidas synchronized, el código compila y de vez en cuando da números incorrectos. En Rust, si intentas compartir un i32 o un Vec normal entre hilos y modificarlo, el programa no compila. El sistema de tipos detecta las condiciones de carrera (data races), a través de los traits Send y Sync. Para un solo contador, un AtomicUsize sería más ligero que un mutex, igual que AtomicInteger. Consulta Concurrencia y Estado compartido.

Lo que te va a hacer tropezar si vienes de Java

Sección titulada «Lo que te va a hacer tropezar si vienes de Java»

Todo desarrollador de Java se encuentra con los mismos baches. Conocerlos de antemano los hace más pequeños.

  • Pasar un valor a una función lo mueve. En Java, pasar un objeto pasa una referencia, y puedes seguir usando tu variable. En Rust, pasar un String o un Vec por valor lo mueve a la función:

    fn greet(name: String) {
    println!("¡Hola, {name}!");
    }
    fn main() {
    let name = String::from("Ada");
    greet(name);
    greet(name);
    }
    error[E0382]: use of moved value: `name`
    --> src/main.rs:8:11
    |
    6 | let name = String::from("Ada");
    | ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
    7 | greet(name);
    | ---- value moved here
    8 | greet(name);
    | ^^^^ value used here after move

    El compilador te dice que name se movió en la línea 7 (“value moved here”) y que después intentas usarlo otra vez en la línea 8. La solución habitual es que la función lo tome prestado: fn greet(name: &str) y llamarla con greet(&name). Lee primero Ownership; ahí se explica esta regla, y E0382 explica este error en detalle.

  • El borrow checker (comprobador de préstamos). Puedes tener muchos lectores (&T) o un solo escritor (&mut T) a la vez, nunca las dos cosas. Grafos de objetos que son normales en Java, como un hijo que apunta a su padre o muchos objetos que guardan y modifican un mismo objeto compartido, necesitan punteros inteligentes o un diseño distinto. Las primeras semanas pueden ser frustrantes. Luego se vuelve más fácil, de verdad.

  • No hay herencia, ni protected, ni super. Vas a tener la tentación de recrear jerarquías de clases con traits. Prueba primero con enums y composición; suelen encajar mejor.

  • == compara valores. Por una vez, una buena noticia: a == b entre dos String compara el texto, así que el clásico bug de == frente a .equals() no puede ocurrir. En tus propios structs, == solo funciona después de derivar o implementar PartialEq.

  • BinaryHeap saca el elemento mayor. La PriorityQueue de Java te da primero el menor. En Rust, envuelve los elementos en std::cmp::Reverse para que funcione como un montículo de mínimos (min-heap). Consulta VecDeque y BinaryHeap.

  • El desbordamiento de enteros no es silencioso. Integer.MAX_VALUE + 1 da la vuelta sin avisar en Java. En una compilación debug de Rust, el programa entra en pánico (panic). Di lo que quieres con checked_add, wrapping_add o saturating_add. Consulta Desbordamiento de enteros.

  • String frente a &str, y sin charAt. Los strings de Rust son UTF-8, así que s[0] no compila. Usa s.chars() para recorrer los caracteres. Las funciones suelen recibir &str y los structs suelen guardar String. Consulta Strings en Rust.

  • No hay sobrecarga de métodos. No puedes tener dos métodos print con parámetros distintos. Usa nombres distintos (print, print_with_color), o un parámetro genérico.

  • No hay magia en tiempo de ejecución. No hay escaneo del classpath, ni @Autowired, ni proxies que añaden transacciones alrededor de tus métodos. Todo se conecta de forma explícita en el código, y las macros se ejecutan al compilar. Escribes más, pero siempre puedes ver qué pasa.

  • No hay null ni excepciones. Al principio, tener Option y Result por todas partes parece trabajo extra. Al cabo de unas semanas te darás cuenta de que no has visto ni una NullPointerException ni un stack trace inesperado. Llamar a .unwrap() está bien mientras aprendes, pero en código real es la versión de Rust de “que se caiga y ya está”.

  • Tiempos de compilación. Una compilación desde cero descarga y compila todas las dependencias desde el código fuente, así que es más lenta que javac. Las compilaciones incrementales son mucho más rápidas, y cargo check (que se salta la generación de código) es lo que más vas a ejecutar mientras editas. La buena noticia al otro lado: el programa arranca al instante, sin calentamiento de la JVM.

  • Async necesita un runtime. No hay hilos virtuales. Una async fn no hace nada hasta que un ejecutor la ejecuta, así que los proyectos web añaden tokio y ponen #[tokio::main] en main. Consulta Async/Await en Rust.

Una ruta de aprendizaje para desarrolladores de Java

Sección titulada «Una ruta de aprendizaje para desarrolladores de Java»

No necesitas leer este sitio en orden. Esta ruta se salta lo que Java ya te enseñó y dedica el tiempo a lo que Rust hace distinto:

  1. Empezar con Rust y Herramientas de Rust: instala Rust, elige un editor y ejecuta cargo new.
  2. Variables y Tipos de datos: léelas por encima, sobre todo para aprender let mut y los tipos numéricos.
  3. Ownership, luego Borrowing y luego Slices: las páginas más importantes para ti. Tómate tu tiempo.
  4. Strings: String frente a &str.
  5. Structs y Enums, luego Match y Coincidencia de patrones.
  6. Option y Result, luego Errores personalizados y anyhow y thiserror.
  7. Traits, Genéricos, Traits comunes y Trait objects: tus interfaces y clases abstractas.
  8. Closures e Iteradores: tus lambdas y tus streams.
  9. Vectores, HashMap y VecDeque y BinaryHeap.
  10. Módulos, Cargo y crates y Pruebas.
  11. Lifetimes y Punteros inteligentes.
  12. Concurrencia, Estado compartido, Canales y Async/Await.
  13. JSON con Serde, Logging y Bases de datos, y después construye el proyecto de API web.
  14. ¿Quieres acelerar una aplicación Java que ya tienes? Lee FFI para ver cómo expone Rust funciones extern "C".

Los dos ejercicios te dan un poco de Java. Pásalo a Rust.

1. Pasa a Rust un record y una cadena de streams

Sección titulada «1. Pasa a Rust un record y una cadena de streams»

Este código imprime los estudiantes que aprobaron, si alguien sacó la nota máxima y la mejor nota. Pista: los iteradores tienen any (como anyMatch) y max (que devuelve un Option, como OptionalInt). Para Collectors.joining, recoge los nombres en un Vec<&str> y llama a .join(", ").

record Student(String name, int score) {}
List<Student> students = List.of(
new Student("Ada", 91), new Student("Brian", 42),
new Student("Grace", 100), new Student("Ken", 67));
String passed = students.stream()
.filter(s -> s.score() >= 50)
.map(Student::name)
.collect(Collectors.joining(", "));
boolean perfect = students.stream().anyMatch(s -> s.score() == 100);
int best = students.stream().mapToInt(Student::score).max().orElse(0);
System.out.println("passed: " + passed);
System.out.println("perfect score: " + perfect);
System.out.println("best: " + best);
Ver solución
#[derive(Debug, Clone, PartialEq)]
struct Student {
name: String,
score: u32,
}
fn student(name: &str, score: u32) -> Student {
Student { name: name.to_string(), score }
}
fn main() {
let students = vec![
student("Ada", 91),
student("Brian", 42),
student("Grace", 100),
student("Ken", 67),
];
let passed: Vec<&str> = students
.iter()
.filter(|s| s.score >= 50)
.map(|s| s.name.as_str())
.collect();
let perfect = students.iter().any(|s| s.score == 100);
let best = students.iter().map(|s| s.score).max().unwrap_or(0);
println!("aprobados: {}", passed.join(", "));
println!("nota máxima: {perfect}");
println!("mejor nota: {best}");
}

Salida:

aprobados: Ada, Grace, Ken
nota máxima: true
mejor nota: 100
▶ Pruébalo tú mismo

Los derives te dan lo que un record de Java te da gratis: un texto de depuración ({:?}), copiar (.clone()) y comparar por valor (==). La función auxiliar student hace el papel del constructor del record. A diferencia de un stream de Java, puedes llamar a students.iter() tantas veces como quieras, porque cada llamada crea un iterador nuevo sobre el mismo Vec.

2. Pasa a Rust una excepción comprobada con causa

Sección titulada «2. Pasa a Rust una excepción comprobada con causa»

Este método lee una línea port=<número> de un archivo de configuración. Lanza una ConfigException comprobada, y envuelve la NumberFormatException que le llega de Integer.parseInt. Pásalo a Rust de forma que read_port devuelva un Result<u16, String>. Pista: line.split_once('=') devuelve un Option<(&str, &str)>; conviértelo en un Result con .ok_or_else(...). .parse::<u16>() devuelve un Result, y .map_err(...) cambia su error por tu propio mensaje, igual que envolver una excepción dentro de otra.

class ConfigException extends Exception {
ConfigException(String message) { super(message); }
}
static int readPort(String line) throws ConfigException {
String[] parts = line.split("=", 2);
if (parts.length != 2 || !parts[0].trim().equals("port"))
throw new ConfigException("expected port=<number>, got '" + line + "'");
try {
return Integer.parseInt(parts[1].trim());
} catch (NumberFormatException e) {
throw new ConfigException("bad port number: " + parts[1].trim());
}
}
for (String line : List.of("port = 8080", "port=eighty", "host=localhost")) {
try { System.out.println("port is " + readPort(line)); }
catch (ConfigException e) { System.out.println("config error: " + e.getMessage()); }
}
Ver solución
fn read_port(line: &str) -> Result<u16, String> {
let expected = || format!("se esperaba port=<número>, pero llegó '{line}'");
let (key, value) = line.split_once('=').ok_or_else(expected)?;
if key.trim() != "port" {
return Err(expected());
}
let value = value.trim();
value
.parse::<u16>()
.map_err(|_| format!("número de puerto no válido: {value}"))
}
fn main() {
for line in ["port = 8080", "port=eighty", "host=localhost"] {
match read_port(line) {
Ok(port) => println!("el puerto es {port}"),
Err(e) => println!("error de configuración: {e}"),
}
}
}

Salida:

el puerto es 8080
error de configuración: número de puerto no válido: eighty
error de configuración: se esperaba port=<número>, pero llegó 'host=localhost'
▶ Pruébalo tú mismo

La cláusula throws ConfigException se convierte en el tipo de retorno Result, el try/catch alrededor de parseInt se convierte en un solo map_err, y el throw se convierte en Err(...). Usar u16 para el puerto es un pequeño extra: parse rechaza por sí solo cualquier número mayor que 65535, algo que el int de Java no haría. El pequeño closure expected construye el mensaje de error solo cuando hace falta, como un supplier de orElseThrow(() -> ...).

  • Mucho de Java te sirve en Rust: los genéricos, las interfaces (como traits), las lambdas (como closures) y las cadenas de streams (como iteradores).
  • Los grandes cambios son el ownership en lugar de un recolector de basura, Option en lugar de null, Result y ? en lugar de excepciones, y traits más composición en lugar de herencia.
  • Los genéricos se compilan para cada tipo (monomorfización), así que no hay borrado de tipos ni números envueltos.
  • Los datos compartidos entre hilos viven dentro de un Mutex (envuelto en un Arc), y olvidarse de bloquearlo es un error de compilación, no una condición de carrera.
  • Cargo sustituye a Maven o Gradle; clippy y rustfmt sustituyen a Checkstyle, SpotBugs y los formateadores; los tests vienen incluidos.
  • En lugar de Spring Boot, mira axum o actix-web; en lugar de JPA, SeaORM o Diesel; en lugar de JDBC y jOOQ, sqlx; y en lugar de Jackson, usa serde.

Comprueba lo aprendido

1. Una clase abstracta de Java tiene un método abstracto y un método concreto que lo llama. ¿Qué se usa normalmente en Rust en su lugar?

2. La PriorityQueue de Java devuelve primero el elemento menor. ¿Qué devuelve BinaryHeap::pop en Rust?

3. ¿Por qué una función genérica de Rust puede llamar a T::default(), mientras que en Java no puedes escribir new T()?

4. Compartes un Vec normal entre hilos e intentas hacer push sin un Mutex. ¿Qué pasa en Rust?

Puntuación: 0 / 4

  • El lenguaje de programación Rust: el libro oficial, en su traducción de la comunidad. Los capítulos 4 (ownership), 9 (manejo de errores), 10 (genéricos y traits) y 16 (concurrencia) son los más importantes si vienes de Java.
  • std::collections (en inglés): la guía de la propia biblioteca estándar para elegir entre Vec, VecDeque, HashMap, BTreeMap y el resto.
  • JEP 454: Foreign Function & Memory API (en inglés): la función del JDK 22 para llamar a bibliotecas nativas, incluidas las de Rust, desde Java sin escribir código JNI.
  • El crate jni en docs.rs (en inglés): bindings de Rust para JNI, por si necesitas llamar a Rust desde JDK más antiguos o volver a llamar a Java.
  • Are We Web Yet? (en inglés): un resumen de la comunidad sobre los frameworks web, las bibliotecas de bases de datos y más de Rust, por si buscas tu stack al estilo Spring Boot.