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#.
Conceptos del lenguaje: de Java a Rust
Sección titulada «Conceptos del lenguaje: de Java a Rust»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 |
Colecciones
Sección titulada «Colecciones»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.
Herramientas: de Maven y Gradle a Cargo
Sección titulada «Herramientas: de Maven y Gradle a Cargo»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.
Web y redes
Sección titulada «Web y redes»| 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 |
Datos y mensajería
Sección titulada «Datos y mensajería»| 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.
Piezas del día a día
Sección titulada «Piezas del día a día»| 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 mesLinus gana 6000 al mesUn 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) {}
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 { } 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 registradoenviando a [email protected]Some("example.com")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 20error: faltan 30error: la cantidad debe ser mayor que ceroFí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, AdaHelsinki: 200London: 120New York: 230Igual 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 bytesun bool ocupa 1 byteEl 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 = 4000Esta 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
Stringo unVecpor 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` trait7 | greet(name);| ---- value moved here8 | greet(name);| ^^^^ value used here after moveEl compilador te dice que
namese 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 congreet(&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, nisuper. 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 == bentre dosStringcompara 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 implementarPartialEq. -
BinaryHeapsaca el elemento mayor. LaPriorityQueuede Java te da primero el menor. En Rust, envuelve los elementos enstd::cmp::Reversepara 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 + 1da la vuelta sin avisar en Java. En una compilación debug de Rust, el programa entra en pánico (panic). Di lo que quieres conchecked_add,wrapping_addosaturating_add. Consulta Desbordamiento de enteros. -
Stringfrente a&str, y sincharAt. Los strings de Rust son UTF-8, así ques[0]no compila. Usas.chars()para recorrer los caracteres. Las funciones suelen recibir&stry los structs suelen guardarString. Consulta Strings en Rust. -
No hay sobrecarga de métodos. No puedes tener dos métodos
printcon 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
nullni excepciones. Al principio, tenerOptionyResultpor todas partes parece trabajo extra. Al cabo de unas semanas te darás cuenta de que no has visto ni unaNullPointerExceptionni 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, ycargo 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 fnno hace nada hasta que un ejecutor la ejecuta, así que los proyectos web añaden tokio y ponen#[tokio::main]enmain. 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:
- Empezar con Rust y Herramientas de Rust: instala Rust, elige un editor y ejecuta
cargo new. - Variables y Tipos de datos: léelas por encima, sobre todo para aprender
let muty los tipos numéricos. - Ownership, luego Borrowing y luego Slices: las páginas más importantes para ti. Tómate tu tiempo.
- Strings:
Stringfrente a&str. - Structs y Enums, luego Match y Coincidencia de patrones.
- Option y Result, luego Errores personalizados y anyhow y thiserror.
- Traits, Genéricos, Traits comunes y Trait objects: tus interfaces y clases abstractas.
- Closures e Iteradores: tus lambdas y tus streams.
- Vectores, HashMap y VecDeque y BinaryHeap.
- Módulos, Cargo y crates y Pruebas.
- Lifetimes y Punteros inteligentes.
- Concurrencia, Estado compartido, Canales y Async/Await.
- JSON con Serde, Logging y Bases de datos, y después construye el proyecto de API web.
- ¿Quieres acelerar una aplicación Java que ya tienes? Lee FFI para ver cómo expone Rust funciones
extern "C".
Práctica
Sección titulada «Práctica»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, Kennota máxima: truemejor nota: 100Los 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 8080error de configuración: número de puerto no válido: eightyerror de configuración: se esperaba port=<número>, pero llegó 'host=localhost'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(() -> ...).
Resumen
Sección titulada «Resumen»- 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,
Optionen lugar denull,Resulty?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 unArc), 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 que aprendiste
Sección titulada «Comprueba lo que aprendiste»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
Para leer más
Sección titulada «Para leer más»- 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,BTreeMapy 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.