Introduction
This is a high-level guide for Java developers who are completely new to the Rust programming language. Some concepts and constructs translate fairly well between Java and Rust, but which may be expressed differently, whereas others are a radical departure, like memory management. This guide provides a brief comparison and mapping of those constructs and concepts with concise examples.
This work is essentially a fork of a similar open-source project by Microsoft: Rust for C#/.NET Developers.
This guide is not meant to be a replacement for proper learning of the Rust language via its official documentation (for example the excellent Rust book). Instead, this guide should be viewed as a resource that can help answer some questions quickly, like: Does Rust support inheritance, interfaces, virtual threads, etc.? Or more generally, how can I do X in Rust? (based on your Java knowledge).
Assumptions:
- Reader is relatively comfortable with Java.
- Reader is completely new to Rust.
Goals:
- Provide a brief comparison and mapping of various Java topics to their counterparts in Rust.
- Provide links to other Rust references, books and articles for further reading on various topics.
Non-goals:
- Discussion of design patterns and architectures.
- Tutorial on the Rust language.
- Make reader proficient in Rust after reading this guide.
- While there are short examples that contrast Java and Rust code for some topics, this guide is not meant to be a cookbook of coding recipes in the two languages.
Here's the TLDR for a Java developer encountering Rust for the first time:
Feature | Java | Rust | Note |
---|---|---|---|
Classes | Yes | No | See note 1. |
Interfaces | Yes | No | See note 2. |
Enums | Yes | Yes | See note 3. |
Generics | Yes | Yes | |
Exceptions | Yes | No | |
Virtual threads | Yes | No | |
Asynchronous Programming | No | Yes | |
Garbage Collector | Yes | No | See note 4. |
Notes:
-
Rust has no classes. It has structures (
struct
) instead. -
Rust has a concept similar to interfaces called
Traits
. -
Enums in Rust are more powerful. Rust enums are most similar to algebraic data types in functional languages, such as OCaml and Haskell.
-
Rust does not have a garbage collector. Memory management is accomplished through the concept of ownership, which is arguably one of Rust's most distinctive features.
License
Portions Copyright © Microsoft Corporation.
Portions Copyright © 2010 The Rust Project Developers.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE
Contributing
You are invited to contribute to this guide by opening issues and submitting pull requests!
Here are some ideas 💡 for how and where you can help most with contributions:
-
Fix any spelling or grammatical mistakes you see as you read.
-
Fix technical inaccuracies.
-
Fix logical or compilation errors in code examples.
-
Expand an explanation to provide more context or improve the clarity of some topic or concept.
-
Keep it fresh with changes in Java and Rust. For example, if there is a change in Java or Rust that brings the two languages closer together, then some parts, including sample code, may need revision.
If you're making a small to modest correction, such as fixing a spelling mistake or a syntax error in a code example, then feel free to submit a pull request directly. For changes that may require a large effort on your part (and reviewers as a result), it is strongly recommended that you submit an issue and seek approval of the author/maintainer before investing your time.
Making quick contributions has been made super simple. If you see an error on a page and happen to be online, you can click the edit icon 📝 in the top right corner of the page to edit the Markdown source of the content and submit a change.
Contribution Guidelines
-
Stick to the goals of this guide laid out in the introduction; put another way, avoid the non-goals!
-
Prefer to keep text short and use short, concise and realistic code examples to illustrate a point.
-
As much as it is possible, always provide and compare examples in Rust and Java.
-
Feel free to use latest Java/Rust language features if it makes an example simpler, concise and alike across the two languages.
-
Make example code as self-contained as possible and runnable (unless the idea is to illustrate a compile-time or run-time error).
Getting Started
Rust Playground
The easiest way to get started with Rust without needing any local installation is to use the Rust Playground. It is a minimal development front-end that runs in the Web browser and allows writing and running Rust code.
Dev Container
The execution environment of the Rust Playground has some limitations, such as total compilation/execution time, memory and networking so another option that does not require installing Rust would be to use a dev container, such as the one provided in the repository https://github.com/microsoft/vscode-remote-try-rust. Like Rust Playground, the dev container can be run directly in a Web browser using GitHub Codespaces or locally using Visual Studio Code.
Local Install
For a complete local installation of Rust compiler and its development tools, see the Installation section of the Getting Started chapter in the The Rust Programming Language book, or the Install page at rust-lang.org.
Hello World
In keeping with the programming tradition, here's a simple Hello World
program in Java:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World.");
}
}
And here's the equivalent in Rust:
fn main() {
println!("Hello World.");
}
A few things to note:
- There's much less ceremony in the Rust version of the program
- In Rust, a function is defined using the
fn
keyword - In both cases, we need a
main
funtion/method as the entry point to our program - Unlike in Java, Rust's
main
function isn't contained in a class - Rust uses the
println!
macro to print the string to the output (notice the bang!
)
In Java's case, it's clear from the method signature that the main method returns nothing (hinted by the use of void
as the return type).
Although it's not clear from the function signature, the Rust version also returns nothing. In the absense of an explicit return type, a Rust function returns void, which is represented by ()
.
This slightly modified Rust version works exactly the same way as the one above:
fn main() -> () {
println!("Hello World.");
}
Language Features
This section compares Java and Rust language features. Here's what is covered in this section:
- Data Types
- Variables and Constants
- Strings
- Collection Types
- Functions
- Control Flow
- Custom Types
- Lambdas and Closures
- Streams and Iterators
- Pattern Matching
- Packages and Modules
- Equality
- Generics
- Polymorphism
- Inheritance
- Error Handling
- Nullability and Optionality
- Conversion and Casting
- Annotations
- Smart Pointers
- Documentation Comments
Data Types
The following table lists the primitive types in Rust and their equivalent in Java:
Rust | Java | Java Wrapper Class | Note |
---|---|---|---|
bool | boolean | Boolean | |
char | char | Character | See note 1. |
i8 | byte | Byte | |
i16 | short | Short | |
i32 | int | Integer | See note 2. |
i64 | long | Long | |
i128 | |||
isize | See note 3. | ||
u8 | |||
u16 | |||
u32 | |||
u64 | |||
u128 | |||
usize | See note 3. | ||
f32 | float | Float | |
f64 | double | Double | |
() | void | Void | See note 4. |
Notes:
-
char
in Rust andCharacter
in JVM have different definitions. In Rust, achar
is 4 bytes wide that is a Unicode scalar value, but in the Java Platform, aCharacter
is 2 bytes wide (16-bit fixed-width) and stores the character using the UTF-16 encoding. For more information, see the Rustchar
documentation. -
Unlike Java (and like C/C++/C#), Rust makes a distinction between signed and unsigned integer types. While all integral types in Java represent signed numbers, Rust provides both signed (e.g.
i32
) and unsigned (e.g.u32
) integer types. -
Rust also provides the
isize
andusize
types that depend on the architecture of the machine your program is running on: 64 bits if you're on a 64-bit architecture and 32 bits if you're on a 32-bit architecture. -
While a unit
()
(an empty tuple) in Rust is an expressible value, the closest cousin in Java would bevoid
to represent nothing.
See also:
Variables and Constants
Variables
Consider the following example around variable assignment in Java:
int x = 5;
And the same in Rust:
let x: i32 = 5;
So far, the only visible difference between the two languages is that the position of the type declaration is different. Also, both Java and Rust are type-safe: the compiler guarantees that the value stored in a variable is always of the designated type. The example can be simplified by using the compiler's ability to automatically infer the types of the variable. In Java:
// Note: this applies only to local variables, i.e. those declared within a method
var x = 5; // type inferred as int
In Rust:
let x = 5; // type inferred as i32
When expanding the first example to update the value of the variable (reassignment), the behavior of Java and Rust differ:
var x = 5;
x = 6;
System.out.println(x); // 6
In Rust, the identical statement will not compile:
let x = 5;
x = 6; // Error: cannot assign twice to immutable variable 'x'.
println!("{}", x);
In Rust, variables are immutable by default. Once a value is bound to a name,
the variable's value cannot be changed. Variables can be made mutable by
adding mut
in front of the variable name:
let mut x = 5;
x = 6;
println!("{}", x); // 6
Rust offers an alternative to fix the example above that does not require mutability through variable shadowing:
let x = 5;
let x = 6;
println!("{}", x); // 6
Java also supports shadowing, e.g. locals can shadow fields and type members can shadow members from the base type. In Rust, the above example demonstrates that shadowing also allows to change the type of a variable without changing the name, which is useful if one wants to transform the data into different types and shapes without having to come up with a distinct name each time.
Constants
In Java, a constant is a static final
field:
static final double GOLDEN_RATIO = 1.618034;
The same in Rust looks like this:
const GOLDEN_RATIO: f64 = 1.618034;
See also:
- Data races and race conditions for more information around the implications of mutability
- Scope and shadowing
- Memory management for explanations around moving and ownership
- Constants in Rust
Strings
There are two string types in Rust: String
and &str
. The former is
allocated on the heap and the latter is a slice of a String
or a &str
.
The mapping of those to Java is shown in the following table:
Rust | Java | Note |
---|---|---|
&str | String | see Note 1. |
Box<str> | String | see Note 2. |
String | String | |
String (mutable) | StringBuilder | see Note 2. |
There are differences in working with strings in Rust and Java, but the
equivalents above should be a good starting point. One of the differences is
that Rust strings are UTF-8 encoded, but JVM strings are UTF-16 encoded.
Further JVM strings are immutable, but Rust strings can be mutable when declared
as such, for example let s = &mut String::from("hello");
.
There are also differences in using strings due to the concept of ownership. To read more about ownership with the String Type, see the Rust book.
Notes:
-
In Rust,
&str
(pronounced: string slice) is an immutable string reference type. -
The
Box<str>
type in Rust is equivalent to theString
type in JVM. The difference between theBox<str>
andString
types in Rust is that the former stores pointer and size while the latter stores pointer, size, and capacity, allowingString
to grow in size. This is similar to theStringBuilder
type in JVM once the RustString
is declared mutable.
Java:
String str = "Hello, World!";
StringBuilder sb1 = new StringBuilder("Hello, World!");
StringBuilder sb2 = new StringBuilder();
sb2.append("Hello");
Rust:
let str1: &str = "Hello, World!";
let str2 = Box::new("Hello World!");
let mut sb1 = String::from("Hello World!");
let mut sb2 = String::new();
sb2.push_str("Hello");
String Literals
String literals in Java are immutable String
types and allocated on the heap.
In Rust, they are &'static str
, which is immutable and has a global lifetime
and does not get allocated on the heap; they're embedded in the compiled binary.
Java
String str = "Hello, World!";
Rust
let str: &'static str = "Hello, World!";
Unlike Java, Rust can represent verbatim string literals as raw string literals.
Rust
let str = r#"Hello, \World/!"#;
String Interpolation
Java lacks native support for String interpolation1 in comparison to languages like C#. The most common way of implementing string interpolation in Java is by using the format()
method from the String
class. Here's an example:
String name = "John";
int age = 42;
String result = String.format("Person { Name: %s, Age: %d }", name, age);
// Alternative using the '+' operator
String result2 = "Person { Name: " + name + ", Age: " + age + " }";
Like Java, Rust does not have a built-in string interpolation feature. Instead, the
format!
macro is used to format a string. The following example shows how to
use string interpolation in Rust:
let name = "John";
let age = 42;
let result = format!("Person {{ name: {name}, age: {age} }}");
Custom types can also be interpolated in Java due to the fact that
the toString()
method is available for each type as it's inherited from Object
.
public class Person {
private String name;
private int age;
// getters and setters omitted
@Override
public String toString() {
return String.format("Person { Name: %s, Age: %d }", name, age);
}
}
// Calling from main method
Person person = new Person();
person.setName("John");
person.setAge(42);
System.out.println(person);
In Rust, there is no default formatting implemented/inherited for each type.
Instead, the std::fmt::Display
trait must be implemented for each type that
needs to be converted to a string.
use std::fmt::*;
struct Person {
name: String,
age: i32,
}
impl Display for Person {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(f, "Person {{ name: {}, age: {} }}", self.name, self.age)
}
}
let person = Person {
name: "John".to_owned(),
age: 42,
};
println!("{person}");
Another option is to use the std::fmt::Debug
trait. The Debug
trait is
implemented for all standard types and can be used to print the internal
representation of a type. The following example shows how to use the derive
attribute to print the internal representation of a custom struct using the
Debug
macro. This declaration is used to automatically implement the Debug
trait for the Person
struct:
#[derive(Debug)]
struct Person {
name: String,
age: i32,
}
let person = Person {
name: "John".to_owned(),
age: 42,
};
println!("{person:?}");
Note: Using the :? format specifier will use the
Debug
trait to print the struct, where leaving it out will use theDisplay
trait.
See also:
Java now has String Templates as a preview feature in Java 21 and Java 22.
Collection Types
Commonly used collection types in Rust and their mapping to Java:
Rust | Java | Java Interface | Note |
---|---|---|---|
Array | Array | see Note 1. | |
Vec | ArrayList | List | |
HashMap | HashMap | Map | |
HashSet | HashSet | Set | |
Tuple | see Note 2. | ||
LinkedList | LinkedList | List | see Note 3. |
Notes:
-
Java provides the
Arrays
utility class for manipulating arrays. -
Unlike Rust, Java does not have the
Tuple
type. -
In both Rust and Java the
LinkedList
collection is implemented using a doubly-linked list.
Arrays
Fixed arrays are supported the same way in Rust and Java.
Java:
int[] someArray = new int[] { 1, 2 };
Rust:
let some_array: [i32; 2] = [1,2];
Notice the type of some_array
: [T; N]
- it indicates both the type of elements of the array and the size of the array (which is fixed at compile time).
Accessing array elements is similar in both languages:
Java:
int firstElement = someArray[0];
int secondElement = someArray[1];
System.out.println(firstElement); // prints: 1
System.out.println(secondElement); // prints: 2
Rust:
let first_element = some_array[0];
let second_element = some_array[1];
println!("{}", first_element); // prints: 1
println!("{}", second_element); // prints: 2
ArrayList
In Rust, the equivalent of Java's ArrayList<E>
is Vec<T>
. Arrays can be converted
to Vecs and vice versa.
Java:
List<String> someList = new ArrayList<>(Arrays.asList("a", "b"));
someList.add("c");
Rust:
let mut some_list = vec![
"a".to_owned(),
"b".to_owned()
];
some_list.push("c".to_owned());
HashMap
In both Rust and Java, a HashMap is represented as HashMap<K, V>
.
Java:
Map<String, String> someMap = new HashMap<>(Map.of("Foo", "Bar", "Baz", "Qux"));
someMap.put("hi", "there");
Rust:
let mut some_map = HashMap::from([
("Foo".to_owned(), "Bar".to_owned()),
("Baz".to_owned(), "Qux".to_owned())
]);
some_map.insert("hi".to_owned(), "there".to_owned());
HashSet
In Rust, the equivalent of Java's HashSet<E>
is HashSet<T>
.
Java:
Set<String> someSet = new HashSet<>(Set.of("a", "b"));
someSet.add("c");
Rust:
let mut some_set = HashSet::from(["a".to_owned(), "b".to_owned()]);
some_set.insert("c".to_owned());
See also:
Functions
Unlike in Java where methods are mainly used to add behaviour to types (and therefore are tied to those types), Rust supports stand-alone functions. Rust also supports methods (and associated functions) that are tied to types (structs
and enums
).
Consider the following example method in Java:
// A method to calculate the area of a rectangle
double areaOfRectangle(double length, double width) {
return length * width;
}
This is how the equivalent function looks like in Rust:
// A function to calculate the area of a rectangle
fn area_of_rectangle(length: f64, width: f64) -> f64 {
length * width // No semi-colon needed here. The expression is evaluated and the result returned
}
This would still work but it's not idiomatic Rust:
fn area_of_rectangle(length: f64, width: f64) -> f64 {
return length * width; // using a return statement works, but it's not idiomatic Rust
}
Here are a few things to note:
- In Rust, functions are defined with
fn
. - Rust uses
snake_case
for function names (and for variable names as well). - Unlike in Java, types are declared second in Rust (
length: f64
). - In Rust,
->
signifies the return type of a function. - In Rust, the idiomatic way to return from a function is to not terminate with semi-colon.
Control Flow
Like Java, Rust has control flow constructs like if
expressions, while
and for
loops.
Consider the following code snippet in Java:
int number = 50;
if (number % 3 == 0) {
System.out.println("fizz");
} else if (number % 5 == 0) {
System.out.println("buzz");
} else {
System.out.println("nothing special...");
}
This is the equivalent in Rust:
let number = 50;
if number % 3 == 0 {
println!("fizz");
} else if number % 5 == 0 {
println!("buzz");
} else {
println!("nothing special...");
}
Notice that in Rust we don't have parenthesis around the condition, like in Java.
Consider the following while
loop in Java:
int number = 10;
while (number > 0) {
System.out.println(number);
number--; // or number = number - 1; or number -= 1;
}
This is the equivalent in Rust:
let mut number = 10;
while number > 0 {
println!("{}", number);
number -= 1; // or number = number - 1;
}
Note that number--
doesn't work in Rust. Also, number
is declared as mut
.
Looping through a collection with for
Java supports two types of for
loops:
- The traditional C-style
for
loop - The enhanced
for
loop (also known asfor-each
loop)
Consider the following Java example that uses C-style for
loop:
int[] numbers = { 10, 20, 30, 40, 50 };
for (int i = 0; i < numbers.length; i++) {
System.out.println(numbers[i]);
}
Rust does not support C-style for
loops. But we can get the same results using the
range
syntax:
let numbers = [10, 20, 30, 40, 50];
for i in 0..numbers.len() {
println!("{}", numbers[i]);
}
Here's how we can use the enhanced for
loop in Java:
int[] numbers = { 10, 20, 30, 40, 50 };
for (int number : numbers) {
System.out.println(number);
}
Here's the equivalent in Rust:
let numbers = [10, 20, 30, 40, 50];
for number in numbers {
println!("{}", number);
}
Defining infinite loops
There are a few ways of defining infinite loops in Java. We'll consider two:
- Using
while
:
while (true) {
// do something
}
- Using
for
:
for (;;) {
// do something
}
Here's how you would define an infinite loop in Rust:
loop {
// do something
}
Both Java and Rust support break
and continue
statements that can be used to break out of loops.
The ternary operator in Java
Consider the following trivial method in Java:
String getResult(int score) {
return score >= 70 ? "pass" : "fail"; // using ternary operator
}
System.out.println(getResult(60)); // prints: fail
System.out.println(getResult(80)); // prints: pass
An equivalent version in Rust would look like this:
fn get_result(score: i32) -> String {
let result = if score >= 70 { "pass" } else { "fail" }; // result has type &str
return result.to_string();
}
fn main() {
println!("{}", get_result(60)); // prints: fail
println!("{}", get_result(80)); // prints: pass
}
An alternative way of writing the Rust function is shown below:
fn get_result(score: i32) -> String {
if score >= 70 { "pass".to_string() } else { "fail".to_string() }
}
Custom Types
This sub-section discusses various topics and constructs related to developing custom types. Here's what is covered:
Classes
Rust does not have classes. It has structures or struct
instead.
Records
Rust does not have records. In Java, records (or record classes
) were added as a stable feature in Java 16.
A Rust constuct that would be considered (approximately) similar to records would be structures or struct
.
Structures (struct
)
Structures in Rust, defined with the struct
keyword, resemble struct
types in C/C++. In Java, the struct type can be approximated using a record
class (when used as a "data carrier"). Here's a high-level comparison between Rust structs and Java record classes:
Rust structs | Java records |
---|---|
They are allocated on the stack by default. | Being reference types, they are allocated on the heap by default. |
A struct can implement multiple traits. | A record can implement multiple interfaces. |
Structs cannot be sub-classed. | Records cannot be sub-classed/extended. |
Methods for a struct are defined separately in an implementation block (impl ). | Just like normal classes in Java, a record class can have methods. |
In Java, a record class
is a way to model an immutable data carrier. In
Rust, a struct
is the primary construct for modeling any data structure (the
other being an enum
). This means that we can also use a normal Java class (with some modifications) to represent a Rust struct.
Here's a simple example of a struct
in Rust:
struct Point {
x: f64,
y: f64,
}
fn main() {
// creating an instance of Point struct
let p = Point {
x: 10.5,
y: 12.4,
};
println!("Value of x is: {}", p.x);
println!("Value of y is: {}", p.y);
}
Here's the equivalent in Java:
record Point(double x, double y) {}
// creating an instance of Point
Point p = new Point(10.5, 12.4);
System.out.println("Value of x is: " + p.x());
System.out.println("Value of y is: " + p.y());
A record
class (or a normal class
) in Java has object equality and copy semantics by default. And so you are able to do things like this out of the box:
Point p1 = new Point(10.5, 12.4);
Point p2 = new Point(10.5, 12.4);
Point p3 = p1;
Point p4 = new Point(8.5, 14.8);
System.out.println(p1 == p2); // false
System.out.println(p1.equals(p2)); // true
System.out.println(p1 == p3); // true
System.out.println(p3.equals(p4)); // false
In Rust on the other hand, you need to annotate the struct with the
#derive
attribute and list the traits to be implemented:
#[derive(Copy, // enables copy-by-value semantics
Clone, // required by Copy
PartialEq, // enables value equality (==)
Eq, // stricter version of PartialEq
Hash // enables hash-ability for use in map types
)]
struct Point {
x: f64,
y: f64,
}
Consider the following record
representing a rectangle in Java:
record Rectangle(double length, double width) {
// Constructor. Please see Note 1.
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
// Accessor method. Please see Note 2.
public double length() {
return length;
}
// Accessor method. Please see Note 2.
public double width() {
return width;
}
// Static method
public static double area(double length, double width) {
return length * width;
}
@Override
public String toString() {
return "Rectangle with length: " + length + " and width: " + width + " has been created.";
}
}
Notes:
-
This is strictly not necessary. Added for the sake of comparison with the Rust version.
-
Having the accessor methods is strictly not necessary. Added for the sake of comparison with the Rust version.
The equivalent in Rust would be:
#![allow(dead_code)]
use std::fmt::*;
struct Rectangle {
length: f64,
width: f64,
}
impl Rectangle {
pub fn new(length: f64, width: f64) -> Self {
Self { length, width }
}
pub fn length(&self) -> f64 {
self.length
}
pub fn width(&self) -> f64 {
self.width
}
pub fn area(&self) -> f64 {
self.length() * self.width()
}
}
impl Display for Rectangle {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
write!(f, "Rectangle with length: {}, and width: {} has been created.", self.length, self.width)
}
}
Note that a record
in Java inherits the toString()
method from the Record
class (which extends Object
) and
therefore it overrides the base implementation to provide a custom string
representation. Since there is no inheritance in Rust, the way a type
advertises support for some formatted representation is by implementing the
Display
trait. This then enables an instance of the struct to
participate in formatting, such as shown in the call to println!
below:
fn main() {
let rect = Rectangle::new(5.2, 4.8);
println!("{rect}"); // Will print: Rectangle with length: 5.2, and width: 4.8 has been created.
}
Interfaces and Traits
Rust doesn't have interfaces like those found in Java. It has traits, instead. Similar to an interface, a trait represents an abstraction and its members form a contract that must be fulfilled when implemented on a type.
Here's a simple example of an interface
in Java:
interface Scalable {
double scaleLength();
double scaleWidth();
}
Here's the equivalent trait
definition in Rust:
trait Scalable {
fn scale_length(&self) -> f64;
fn scale_width(&self) -> f64;
}
In Java, the Rectangle
record (from the previous section on structs) can implement the Scalable
interface as follows:
record Rectangle(double length, double width) implements Scalable {
// details from previous section omitted
@Override
public double scaleLength() {
return length * 2;
}
@Override
public double scaleWidth() {
return width * 2;
}
}
In Rust, the Rectangle
struct (from the previous section on structs) can implement the
Scalable
trait as follows:
impl Scalable for Rectangle {
fn scale_length(&self) -> f64 {
self.length * 2 as f64
}
fn scale_width(&self) -> f64 {
self.width * 2 as f64
}
}
Note that in the previous section on structs, we implemented the Display
trait for the Rectangle struct. Here's how the Display
trait is declared:
pub trait Display {
// Required method
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}
Just the way interfaces can have default methods1 in Java (where a default implementation body is provided as part of the interface definition), so can traits in Rust. The type implementing the interface/trait can subsequently provide a more suitable and/or optimized implementation if needed.
Also, just like in Java interfaces, traits in Rust can define static methods2 (and even constants). Technically, in Rust these are called associated functions and constants.
Extending Interfaces and Traits
In Java, an interface can extend another interface. Let's say we have an interface called Shape
that's declared as follows:
interface Shape {
// default method
default boolean isRectangular() {
return true;
}
}
The Scalable
interface can then extend the Shape
interface, like so:
interface Scalable extends Shape {
double scaleLength();
double scaleWidth();
}
The Rectangle
record that implements the Scalable
interface now has access to the default method defined in the Shape
interface as well.
record Rectangle(double length, double width) implements Scalable {
// you have access to the default method defined in the Shape interface.
}
Similar behaviour can be achieved in Rust using supertraits
and subtraits
. Here's the Shape
trait:
trait Shape {
// default method
fn is_rectangular(&self) -> bool {
true
}
}
The Scalable
trait can then extend the Shape
trait, like so:
trait Scalable: Shape {
fn scale_length(&self) -> f64;
fn scale_width(&self) -> f64;
}
In this case, Shape
is the supertrait while Scalable
is the subtrait.
Now, any type that implements the Scalable
trait must also implement the Shape
trait as well (note that this is slightly different from the Java case with interfaces).
Here's the Rectangle
struct from the previous section on structs.
#![allow(dead_code)]
use std::fmt::*;
struct Rectangle {
length: f64,
width: f64,
}
impl Rectangle {
// details from previous section omitted
}
impl Scalable for Rectangle {
fn scale_length(&self) -> f64 {
self.length * 2 as f64
}
fn scale_width(&self) -> f64 {
self.width * 2 as f64
}
}
impl Shape for Rectangle {}
We can now invoke the default method defined in the Shape
trait as shown below:
fn main() {
let rect = Rectangle::new(5.2, 4.8);
println!("The Shape is a Rectangle: {}", rect.is_rectangular()); // Will print: The Shape is a Rectangle: true
}
Marker Interfaces and Traits
Rust has marker traits, just like Java has marker interfaces. These are empty (no methods declared) traits/interfaces; their main purpose being to communicate certain type behaviour to the compiler.
Cloneable
is an example of a marker interface in Java:
public interface Cloneable { }
Copy
is an example of a marker trait in Rust:
pub trait Copy: Clone { }
Notice how the Copy
trait extends the Clone
trait.
Polymorphic Behaviour
Apart from class hierarchies, interfaces are a core means of achieving polymorphism via dynamic dispatch for cross-cutting abstractions. They enable general-purpose code to be written against the abstractions represented by the interfaces without much regard to the concrete types implementing them.
Since Rust doesn't have classes and consequently type hierarchies based on sub-classing, polymorphism can be achieved using trait objects (in a limited fashion). A trait
object is essentially a v-table (virtual table) identified with the dyn
keyword followed by the trait name, as in dyn Shape
(where Shape
is the
trait name). Trait objects always live behind a pointer, either a reference
(e.g. &dyn Shape
) or the heap-allocated Box
(e.g. Box<dyn Shape>
). This
is somewhat like in Java, where an interface is a reference type such that a
type cast to an interface is automatically boxed onto the managed heap.
One limitation of trait objects is that the original implementing type cannot be recovered. In other words, whereas it's quite common to downcast or test an interface to be an instance of some other interface or sub- or concrete type, the same is not possible in Rust (without additional effort and support).
Default methods in interfaces were introduced in Java 8.
Static methods in interfaces were introduced in Java 8.
Enumerated types (enum
)
In Java, an enum
is a specialized type of class that has limited functionality. An enum holds a small number of possible permissible values of a type.
Here's an example of an enum in Java:
enum PrimaryColor {
RED,
GREEN,
BLUE
}
Rust has an identical syntax for declaring the same enum:
enum PrimaryColor {
Red,
Green,
Blue
}
Being specialized classes in Java, enums can have member fields and methods. Here's an example for illustration purposes:
enum RainbowColor {
RED(1),
ORANGE(2),
YELLOW(3),
GREEN(4),
BLUE(5),
INDIGO(6),
VIOLET(7); // the semi colon at the end of list required for enums with parameters
private final int number;
private final String name;
public int getNumber() {
return number;
}
public String getName() {
return name;
}
RainbowColor(int number) {
this.number = number;
this.name = switch (number) {
case 1 -> "RED";
case 2 -> "ORANGE";
case 3 -> "YELLOW";
case 4 -> "GREEN";
case 5 -> "BLUE";
case 6 -> "INDIGO";
case 7 -> "VIOLET";
default -> throw new RuntimeException("Illegal: " + number);
};
}
}
Here's how we could exercise the RainbowColor
enum:
public class RainbowColorTest {
public static void main(String[] args) {
RainbowColor color = RainbowColor.BLUE;
String name = color.getName();
System.out.println(name); // prints: BLUE
}
}
A slightly similar (not a 1:1 mapping) version of the RainbowColor
enum in Rust is shown below:
#[derive(Debug)] // enables formatting in "{:?}"
enum RainbowColor {
Red = 1,
Orange = 2,
Yellow = 3,
Green = 4,
Blue = 5,
Indigo = 6,
Violet = 7,
}
impl RainbowColor {
fn new(number: i32) -> Result<RainbowColor, Box<dyn std::error::Error>> {
use RainbowColor::*;
match number {
1 => Ok(Red),
2 => Ok(Orange),
3 => Ok(Yellow),
4 => Ok(Green),
5 => Ok(Blue),
6 => Ok(Indigo),
7 => Ok(Violet),
_ => return Err(format!("Illegal: {}", number).into())
}
}
}
The new()
function returns a RainbowColor
in a Result
indicating success
(Ok
) if number
is valid. Otherwise it panics:
let color = RainbowColor::new(5);
println!("{color:?}"); // prints: Ok(Blue)
let color = RainbowColor::new(10);
println!("{color:?}"); // prints: Err("Illegal: 10")
An enum
type in Rust can also serve as a way to design (discriminated) union
types, which allow different variants to hold data specific to each variant.
For example:
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
Enums in Rust are most similar to algebraic data types in functional languages, such as OCaml and Haskell.
Members
Constructors
Rust does not have any notion of constructors. Instead, you just write factory
functions that return an instance of the type. The factory functions can be
stand-alone or associated functions of the type. In Java terms, associated
functions are like having static methods on a type. Conventionally, if there
is just one factory function for a struct
, it's named new
:
struct Rectangle {
length: f64,
width: f64,
}
impl Rectangle {
pub fn new(length: f64, width: f64) -> Self {
Self { length, width }
}
}
Since Rust functions (associated or otherwise) do not support overloading; the
factory functions have to be named uniquely. For example, below are some
examples of so-called constructors or factory functions available on String
:
String::new
: creates an empty string.String::with_capacity
: creates a string with an initial buffer capacity.String::from_utf8
: creates a string from bytes of UTF-8 encoded text.String::from_utf16
: creates a string from bytes of UTF-16 encoded text.
In the case of an enum
type in Rust, the variants act as the constructors.
See the section on enumeration types for more.
See also:
Methods (static & instance-based)
Like Java, Rust types (both enum
and struct
), can have static and
instance-based methods. In Rust-speak, a method is always instance-based and
is identified by the fact that its first parameter is named self
. The self
parameter has no type annotation since it's always the type to which the
method belongs. A static method is called an associated function. In the
example below, new
is an associated function and the rest (length
, width
and area
) are methods of the type:
struct Rectangle {
length: f64,
width: f64,
}
impl Rectangle {
pub fn new(length: f64, width: f64) -> Self {
Self { length, width }
}
pub fn length(&self) -> f64 {
self.length
}
pub fn width(&self) -> f64 {
self.width
}
pub fn area(&self) -> f64 {
self.length() * self.width()
}
}
Properties
In Java, it is generally good practice for fields of a type (e.g. a class) to be private. They are then
protected/encapsulated by property members with accessor methods (getters
and
setters
) to read or write to those fields. The accessor methods can contain extra
logic, for example, to either validate the value when being set or compute a
value when being read. Rust only has methods where a getter is named after the
field (in Rust method names can share the same identifier as a field) and the
setter uses a set_
prefix.
Below is an example showing how property-like accessor methods typically look for a type in Rust:
struct Rectangle {
length: f64,
width: f64,
}
impl Rectangle {
pub fn new(length: f64, width: f64) -> Self {
Self { length, width }
}
// like property getters (each shares the same name as the field)
pub fn length(&self) -> f64 { self.length }
pub fn width(&self) -> f64 { self.width }
// like property setters
pub fn set_length(&mut self, val: f64) { self.length = val }
pub fn set_width(&mut self, val: f64) { self.width = val }
// like computed properties
pub fn area(&self) -> i32 {
self.length() * self.width()
}
}
Visibility/Access modifiers
Java has a number of accessibility or visibility modifiers:
private
protected
package private
public
In Rust, a compilation is built-up of a tree of modules where modules contain
and define items like types, traits, enums, constants and
functions. Almost everything is private by default. One exception is, for
example, associated items in a public trait, which are public by default.
This is similar to how members of a Java interface declared without any public
modifiers in the source code are public by default. Rust only has the pub
modifier to change the visibility with respect to the module tree. There
are variations of pub
that change the scope of the public visibility:
pub(self)
pub(super)
pub(crate)
pub(in PATH)
For more details, see the Visibility and Privacy section of The Rust Reference.
The table below is an approximation of the mapping of Java and Rust modifiers:
Java | Rust | Note |
---|---|---|
private | (default) | See note 1. |
protected | N/A | See note 2. |
package private | pub(crate) | |
public | pub |
-
There is no keyword to denote private visibility; it's the default in Rust.
-
Since there are no class-based type hierarchies in Rust, there is no equivalent of
protected
.
Mutability
When designing a type in Java, it is the responsiblity of the developer to
decide whether the type is mutable or immutable. Java does support an immutable design
for "data carriers" with record classes
.
In Rust, mutability is expressed on methods through the type
of the self
parameter as shown in the example below:
struct Point {
x: f64,
y: f64,
}
impl Point {
pub fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
// self is not mutable
pub fn x(&self) -> f64 { self.x }
pub fn y(&self) -> f64 { self.y }
// self is mutable
pub fn set_x(&mut self, val: f64) { self.x = val }
pub fn set_y(&mut self, val: f64) { self.y = val }
}
Lambdas and Closures
Java has a feature for defining anonymous functions in the form of lambda expressions1. The syntax for a lambda expression in Java looks as follows:
( paramlist ) -> { /* method body */ }
In Rust, anonymous functions are called closures. The syntax for a closure expression in Rust looks as follows:
| paramlist | { /* function body */ }
Note: In both Java and Rust, the types of the parameters will typically be inferred.
In addition to closures, Rust has function pointers that implement all three of the closure traits (Fn
, FnMut
and FnOnce
). Rust makes a distinction between function pointers (where fn
defines a type) and closures: a closure can reference variables from its surrounding lexical scope, but not a function pointer.
While Java doesn't have function pointers, it provides a set of functional interfaces (in the java.util.function
package) that can be used as target types for lambda expressions (and method references). You can also define your own functional interfaces as well (as we do in the Java code example below).
In Rust, functions and methods that accept closures are written with generic types that
are bound to one of the closure traits: Fn
, FnMut
and FnOnce
. When it's time to provide a value for a function pointer or a closure, you would use a closure expression (like |x| x + 1
), which translates to something similar to a lambda expression in Java. Whether the closure expression creates a function pointer or a closure depends on whether the closure expression references its context or not.
When a closure captures variables from its environment then ownership rules come into play because the ownership ends up with the closure. For more information, see the “Moving Captured Values Out of Closures and the Fn Traits” section of the Rust book.
Let's look at a Rust example that showcases all these functionalities:
struct Customer {
name: String,
gross_income: f64,
}
impl Customer {
pub fn new(name: String, gross_income: f64) -> Self {
Self { name, gross_income }
}
pub fn name(&self) -> String {
self.name.clone()
}
pub fn gross_income(&self) -> f64 {
self.gross_income
}
}
// Note: the parameter func is of type fn, which is a function pointer
fn apply_tax_calc_function(func: fn(f64) -> f64, arg: f64) -> f64 {
func(arg)
}
fn main() {
let customer = Customer::new("Jane Doe".to_string(), 50000 as f64);
let name = customer.name();
let income = customer.gross_income();
let tax_function_closure = |income| {
let payable_tax: f64;
if income < 30000 as f64 {
payable_tax = income * 0.05;
} else {
payable_tax = income * 0.06;
}
payable_tax
};
let calculated_tax = apply_tax_calc_function(tax_function_closure, income); // we pass in a closure
println!("The calculated tax for {} is {}", name, calculated_tax); // prints: The calculated tax for Jane Doe is 3000
}
Here's how we can achieve the equivalent functionality in Java:
// we define our own functional interface
@FunctionalInterface
interface TaxFunction {
double calculateTax(double grossIncome);
}
record Customer(String name, double grossIncome) {
public double applyTaxCalcFunction(TaxFunction taxFunc) {
double calculatedTax = taxFunc.calculateTax(grossIncome);
return calculatedTax;
}
}
public class TestClass {
public static void main(String[] args) {
// define a function as a lambda expression and assign the result to a variable
TaxFunction taxFunction = (grossIncome) -> {
double payableTax;
if (grossIncome < 30000) {
payableTax = grossIncome * 0.05;
} else {
payableTax = grossIncome * 0.06;
}
return payableTax;
};
Customer customer = new Customer("Jane Doe", 50000);
String name = customer.name();
double calculatedTax = customer.applyTaxCalcFunction(taxFunction); // we pass in a lambda expression
System.out.println("The calculated tax for " + name + " is "+ calculatedTax); // prints: The calculated tax for Jane Doe is 3000.0
}
}
Higher-order functions
Higher-order functions are functions that accept other functions as arguments to allow for the caller to participate in the code of the called function. As we've already seen in the code examples above, in both Rust and Java, we can pass closures and lambda expressions to functions. But unlike in Java, Rust also allows regular functions to be passed into other functions:
struct Customer {
name: String,
gross_income: f64,
}
impl Customer {
pub fn new(name: String, gross_income: f64) -> Self {
Self { name, gross_income }
}
pub fn name(&self) -> String {
self.name.clone()
}
pub fn gross_income(&self) -> f64 {
self.gross_income
}
}
// Note: the parameter func is of type fn, which is a function pointer
fn apply_tax_calc_function(func: fn(f64) -> f64, arg: f64) -> f64 {
func(arg)
}
// define a regular function
fn tax_function_regular(income: f64) -> f64 {
let payable_tax: f64;
if income < 30000 as f64 {
payable_tax = income * 0.05;
} else {
payable_tax = income * 0.06;
}
payable_tax
}
fn main() {
let customer = Customer::new("Jane Doe".to_string(), 50000 as f64);
let name = customer.name();
let income = customer.gross_income();
let calculated_tax = apply_tax_calc_function(tax_function_regular, income); // we pass in a regular function
println!("The calculated tax for {} is {}", name, calculated_tax); // prints: The calculated tax for Jane Doe is 3000
}
Lambda expressions were introduced in Java 8.
Streams and Iterators
This section discusses Java's Stream API within the context and for the purpose of transforming sequences (Iterator<E>
/Iterable<T>
) and typically collections like lists, sets and maps.
Iterable<T> Interface
The equivalent of Java's Iterable<T>
in Rust is IntoIterator
.
Just as an implementation of Iterable<T>.iterator()
returns a
Iterator<T>
in Java, an implementation of IntoIterator::into_iter
returns an Iterator
. However, when it's time to iterate over the
items of a container advertising iteration support through the said types,
both languages offer syntactic sugar in the form of looping constructs for
iteratables. In Java, there is the enhanced for
statement (also known as the for-each
loop).
int[] values = { 1, 2, 3, 4, 5 };
StringBuilder output = new StringBuilder();
for (var value : values) {
if (output.length() > 0) {
output.append(", ");
}
output.append(value);
}
System.out.println(output); // prints: 1, 2, 3, 4, 5
In Rust, the equivalent is simply for
:
use std::fmt::Write;
fn main() {
let values = [1, 2, 3, 4, 5];
let mut output = String::new();
for value in values {
if output.len() > 0 {
output.push_str(", ");
}
// ! discard/ignore any write error
_ = write!(output, "{value}");
}
println!("{output}"); // Prints: 1, 2, 3, 4, 5
}
The for
loop over an iterable essentially gets desugared to the following:
use std::fmt::Write;
fn main() {
let values = [1, 2, 3, 4, 5];
let mut output = String::new();
let mut iter = values.into_iter(); // get iterator
while let Some(value) = iter.next() { // loop as long as there are more items
if output.len() > 0 {
output.push_str(", ");
}
_ = write!(output, "{value}");
}
println!("{output}");
}
Here's a Java example that uses the forEach()
method:
Map<String, String> houses = new HashMap<>(Map.of(
"Stark", "Winter Is Coming",
"Targaryen", "Fire and Blood",
"Lannister", "Hear Me Roar",
"Arryn", "As High as Honor",
"Tully", "Family, Duty, Honor",
"Greyjoy", "We Do Not Sow",
"Baratheon", "Ours is the Fury",
"Tyrell", "Growing Strong",
"Martell", "Unbowed, Unbent, Unbroken",
"Hightower", "We Light the Way"
));
houses.entrySet()
.stream()
.forEach(house -> System.out.println(
"House: " + house.getKey()
+ "," + " Motto: "
+ house.getValue()));
The output looks as follows:
House: Lannister, Motto: Hear Me Roar
House: Targaryen, Motto: Fire and Blood
House: Baratheon, Motto: Ours is the Fury
House: Hightower, Motto: We Light the Way
House: Martell, Motto: Unbowed, Unbent, Unbroken
House: Tyrell, Motto: Growing Strong
House: Tully, Motto: Family, Duty, Honor
House: Stark, Motto: Winter Is Coming
House: Arryn, Motto: As High as Honor
House: Greyjoy, Motto: We Do Not Sow
Here's the Rust equivalent:
use std::collections::HashMap;
fn main() {
let houses = HashMap::from([
("Stark".to_owned(), "Winter Is Coming".to_owned()),
("Targaryen".to_owned(), "Fire and Blood".to_owned()),
("Lannister".to_owned(), "Hear Me Roar".to_owned()),
("Arryn".to_owned(), "As High as Honor".to_owned()),
("Tully".to_owned(), "Family, Duty, Honor".to_owned()),
("Greyjoy".to_owned(), "We Do Not Sow".to_owned()),
("Baratheon".to_owned(), "Ours is the Fury".to_owned()),
("Tyrell".to_owned(), "Growing Strong".to_owned()),
("Martell".to_owned(), "Unbowed, Unbent, Unbroken".to_owned()),
("Hightower".to_owned(), "We Light the Way".to_owned())
]);
for (key, value) in houses.iter() {
println!("House: {}, Motto: {}", key, value);
}
}
The output for the Rust version looks as follows:
House: Baratheon, Motto: Ours is the Fury
House: Stark, Motto: Winter Is Coming
House: Targaryen, Motto: Fire and Blood
House: Martell, Motto: Unbowed, Unbent, Unbroken
House: Lannister, Motto: Hear Me Roar
House: Tyrell, Motto: Growing Strong
House: Arryn, Motto: As High as Honor
House: Tully, Motto: Family, Duty, Honor
House: Hightower, Motto: We Light the Way
House: Greyjoy, Motto: We Do Not Sow
Rust's ownership and data race condition rules apply to all instances and data, and iteration is no exception. So while looping over an array might look straightforward and very similar to Java, one has to be mindful about ownership when needing to iterate the same collection/iterable more than once. The following example iterates the list of integers twice, once to print their sum and another time to determine and print the maximum integer:
fn main() {
let values = vec![1, 2, 3, 4, 5];
// sum all values
let mut sum = 0;
for value in values {
sum += value;
}
println!("sum = {sum}");
// determine maximum value
let mut max = None;
for value in values {
if let Some(some_max) = max { // if max is defined
if value > some_max { // and value is greater
max = Some(value) // then note that new max
}
} else { // max is undefined when iteration starts
max = Some(value) // so set it to the first value
}
}
println!("max = {max:?}");
}
However, the code above is rejected by the compiler due to a subtle
difference: values
has been changed from an array to a Vec<int>
,
a vector, which is Rust's type for growable arrays (like List<E>
in Java).
The first iteration of values
ends up consuming each value as the integers
are summed up. In other words, the ownership of each item in the vector
passes to the iteration variable of the loop: value
. Since value
goes out
of scope at the end of each iteration of the loop, the instance it owns is
dropped. Had values
been a vector of heap-allocated data, the heap memory
backing each item would get freed as the loop moved to the next item. To fix
the problem, one has to request iteration over shared references via
&values
in the for
loop. As a result, value
ends up being a shared
reference to an item as opposed to taking its ownership.
Below is the updated version of the previous example that compiles. The fix is
to simply replace values
with &values
in each of the for
loops.
fn main() {
let values = vec![1, 2, 3, 4, 5];
// sum all values
let mut sum = 0;
for value in &values {
sum += value;
}
println!("sum = {sum}");
// determine maximum value
let mut max = None;
for value in &values {
if let Some(some_max) = max { // if max is defined
if value > some_max { // and value is greater
max = Some(value) // then note that new max
}
} else { // max is undefined when iteration starts
max = Some(value) // so set it to the first value
}
}
println!("max = {max:?}");
}
The ownership and dropping can be seen in action even with values
being an
array instead of a vector. Consider just the summing loop from the above
example over an array of a structure that wraps an integer:
#[derive(Debug)]
struct Int(i32);
impl Drop for Int {
fn drop(&mut self) {
println!("Int({}) dropped", self.0)
}
}
fn main() {
let values = [Int(1), Int(2), Int(3), Int(4), Int(5)];
let mut sum = 0;
for value in values {
println!("value = {value:?}");
sum += value.0;
}
println!("sum = {sum}");
}
Int
implements Drop
so that a message is printed when an instance get
dropped. Running the above code will print:
value = Int(1)
Int(1) dropped
value = Int(2)
Int(2) dropped
value = Int(3)
Int(3) dropped
value = Int(4)
Int(4) dropped
value = Int(5)
Int(5) dropped
sum = 15
It's clear that each value is acquired and dropped while the loop is running.
Once the loop is complete, the sum is printed. If values
in the for
loop
is changed to &values
instead, like this:
for value in &values {
// ...
}
then the output of the program will change radically:
value = Int(1)
value = Int(2)
value = Int(3)
value = Int(4)
value = Int(5)
sum = 15
Int(1) dropped
Int(2) dropped
Int(3) dropped
Int(4) dropped
Int(5) dropped
This time, values are acquired but not dropped while looping because each item
doesn't get owned by the interation loop's variable. The sum is printed ocne
the loop is done. Finally, when the values
array that still owns all the the
Int
instances goes out of scope at the end of main
, its dropping in turn
drops all the Int
instances.
These examples demonstrate that while iterating collection types may seem to have a lot of parallels between Rust and Java, from the looping constructs to the iteration abstractions, there are still subtle differences with respect to ownership that can lead to the compiler rejecting the code in some instances.
See also:
Stream Operations
Java's Stream API offers a set of operations (methods) that can be chained together to form stream pipelines. In Rust, such methods are called adapters.
However, while rewriting an imperative loop as a stream in Java is often beneficial in expressivity, robustness and composability, there is a trade-off with performance. Compute-bound imperative loops usually run faster because they can be optimised by the JIT compiler and there are fewer virtual dispatches or indirect function invocations incurred. The surprising part in Rust is that there is no performance trade-off between choosing to use method chains on an abstraction like an iterator over writing an imperative loop by hand. It's therefore far more common to see the former in code.
The following table lists the most common Stream API methods and their approximate counterparts in Rust.
Java | Rust | Note |
---|---|---|
reduce | reduce | See note 1. |
reduce | fold | See note 1. |
allMatch | all | |
anyMatch | any | |
concat | chain | |
count | count | |
max | max | |
min | min | |
map | map | |
flatMap | flat_map | |
skip | skip | |
limit | take | |
takeWhile | take_while | |
collect | collect | See note 2. |
filter | filter |
-
Java's
reduce
method is overloaded. The one not accepting an identity value is equivalent to Rust'sreduce
, while the one accepting an identity value corresponds to Rust'sfold
. -
collect
in Rust generally works for any collectible type, which is defined as a type that can initialize itself from an iterator (seeFromIterator
).collect
needs a target type, which the compiler sometimes has trouble inferring so the turbofish (::<>
) is often used in conjunction with it, as incollect::<Vec<_>>()
.
The following example shows how similar transforming sequences in Java is to doing the same in Rust. First in Java:
int result = IntStream.range(0, 10)
.filter(x -> x % 2 == 0)
.flatMap(x -> IntStream.range(0, x))
.reduce(0, Integer::sum);
System.out.println(result); // prints: 50
And in Rust:
let result = (0..10)
.filter(|x| x % 2 == 0)
.flat_map(|x| (0..x))
.fold(0, |acc, x| acc + x);
println!("{result}"); // prints: 50
Deferred execution (laziness)
Java streams are lazy: computation on the source data is only performed when a
terminal operation is initiated, and source elements are consumed only as needed. This enables composition or chaining of several (intermediate) operations/methods without causing any side-effects. For example, a stream operation can return an Iterable<T>
that is initialized, but does not produce, compute or materialize any items of T
until iterated. The operation is said to have deferred execution semantics (or lazy evaluation). If each T
is computed as iteration reaches it (as opposed to when iteration begins) then the operation is said to stream the results.
Rust iterators have the same concept of laziness and streaming.
Pattern Matching
Both Java and Rust provide facilities for pattern matching. We already saw some use of pattern matching in the section for enums.
Consider this Java method that makes use of an if-then-else
statement:
String fooBar(Integer number) {
if (number % 2 == 0) {
return "foo";
} else if (number % 3 == 0) {
return "bar";
} else {
throw new RuntimeException("some other number: " + number);
}
}
System.out.println(fooBar(10)); // prints: foo
System.out.println(fooBar(15)); // prints: bar
System.out.println(fooBar(13)); // raises an exception and prints: some other number: 13
We can rewrite the code above to use pattern matching with switch expression
:
String fooBar(Integer number) {
return switch (number) {
case Integer n when n % 2 == 0 -> "foo";
case Integer n when n % 3 == 0 -> "bar";
default -> throw new RuntimeException("some other number: " + number);
};
}
System.out.println(fooBar(10)); // prints: foo
System.out.println(fooBar(15)); // prints: bar
System.out.println(fooBar(13)); // raises an exception and prints: some other number: 13
Here's the Rust equivalent:
fn foo_bar(number: i32) -> String {
match number {
n if n % 2 == 0 => "foo".to_string(),
n if n % 3 == 0 => "bar".to_string(),
_ => panic!("some other number: {}", number)
}
}
fn main() {
println!("{}", foo_bar(10)); // prints foo
println!("{}", foo_bar(15)); // prints: bar
println!("{}", foo_bar(13)); // panics and prints: some other number: 13
}
The if
condition in each of the match arms is called a match guard in Rust.
Let's consider the following Java interface:
public interface Shape {
public double area();
}
And the following types that implement the Shape
interface (overriding the area
method):
record Circle(double radius) implements Shape {
@Override
public double area() {
return Math.PI * radius * radius;
}
}
record Rectangle(double length, double width) implements Shape {
@Override
public double area() {
return length * width;
}
}
record Triangle(double base, double height) implements Shape {
@Override
public double area() {
return 0.5 * base * height;
}
}
Now, here's how we can use pattern matching with switch expression to get the area of each type of shape:
public static double getAreaOfShape(Shape shape) throws IllegalArgumentException {
return switch (shape) {
case Circle c -> c.area();
case Rectangle r -> r.area();
case Triangle t -> t.area();
default -> throw new IllegalArgumentException("Unrecognized shape");
};
}
And this is how we can exercise the method:
Circle shape1 = new Circle(2.0);
double areaOfCircle = getAreaOfShape(shape1);
System.out.println("Area of Circle is: " + areaOfCircle); // prints: Area of Circle is: 12.566370614359172
Rectangle shape2 = new Rectangle(4.0, 2.0);
double areaOfRectangle = getAreaOfShape(shape2);
System.out.println("Area of Rectangle is: " + areaOfRectangle); // prints: Area of Rectangle is: 8.0
Triangle shape3 = new Triangle(4.0, 3.0);
double areaOfTriangle = getAreaOfShape(shape3);
System.out.println("Area of Triangle is: " + areaOfTriangle); // prints: Area of Triangle is: 6.0
Before the addition of pattern matching functionality in Java, this is how we would implement the getAreaOfShape
method:
public static double getAreaOfShape(Shape shape) throws IllegalArgumentException {
if (shape instanceof Rectangle r) {
return r.area();
} else if (shape instanceof Circle c) {
return c.area();
} else if (shape instanceof Triangle t) {
return t.area();
} else {
throw new IllegalArgumentException("Unrecognized shape");
}
}
Here's the Rust equivalent code:
trait Shape {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
length: f64,
width: f64,
}
struct Triangle {
base: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.length * self.width
}
}
impl Shape for Triangle {
fn area(&self) -> f64 {
0.5 * self.base * self.height
}
}
enum ShapeType {
Circle(Circle),
Rectangle(Rectangle),
Triangle(Triangle),
}
We can then get the area using pattern matching as shown below:
fn get_area_of_shape(shape: ShapeType) -> f64 {
match shape {
ShapeType::Circle(circle) => circle.area(),
ShapeType::Rectangle(rectangle) => rectangle.area(),
ShapeType::Triangle(triangle) => triangle.area(),
}
}
And here's how we can use the function:
fn main() {
let shape1 = ShapeType::Circle(Circle { radius: 2.0 });
let area_of_circle = get_area_of_shape(shape1);
println!("The area of circle is {}", area_of_circle); // prints: The area of circle is 12.566370614359172
let shape2 = ShapeType::Rectangle(Rectangle { length: 4.0, width: 2.0 });
let area_of_rectangle = get_area_of_shape(shape2);
println!("The area of rectangle is {}", area_of_rectangle); // prints: The area of rectangle is 8
let shape3 = ShapeType::Triangle(Triangle { base: 4.0, height: 3.0 });
let area_of_triangle = get_area_of_shape(shape3);
println!("The area of triangle is {}", area_of_triangle); // prints: The area of triangle is 6
}
Packages and Modules
Packages are used in Java to organize types, as well as for controlling the scope of types and methods in projects.
The equivalent of a package in Rust is a module. For both Java and Rust, visibility of items can be restricted using access modifiers and visibility modifiers respectively. In Rust, the default visibility is private (with only few exceptions). The equivalent of Java's public
is pub
in Rust, and package-private
corresponds to
pub(crate)
. For more fine-grained access control, refer to the visibility modifiers reference.
Equality
When comparing for equality in Java, this may refer to testing for object equality in
some cases, and in other cases it may refer to testing for reference equality, which tests whether two variables refer/point to the same underlying object in memory. Every custom type can be compared for equality because it inherits from the Object
root class (which provides an equals()
method).
For example, when comparing for object and reference equality in Java:
record Point(int x, int y) {}
var a = new Point(1, 2);
var b = new Point(1, 2);
var c = new Point(2, 2);
var d = a;
System.out.println(a == b); // (1) false
System.out.println(a.equals(b)); // (2) true
System.out.println(a.equals(c)); // (2) false
System.out.println(a == d); // (1) true
System.out.println(a.equals(d)); // true
-
The equality operator
==
is used to compare for reference equality. In this case, variablesa
andb
are referring/pointing to different objects in memory. -
The
equals()
method is used to compare for object equality, i.e. whether two distinct objects have the same content.
Equivalently in Rust:
#[derive(Copy, Clone)]
struct Point(i32, i32);
fn main() {
let a = Point(1, 2);
let b = Point(1, 2);
let c = Point(2, 2);
let d = a;
println!("{}", a == b); // Error: "an implementation of `PartialEq<_>` might be missing for `Point`"
println!("{}", a.eq(&b));
println!("{}", a.eq(&c));
}
The compiler error above illustrates that in Rust equality comparisons are
always related to a trait implementation. To support a comparison using ==
,
a type must implement PartialEq
.
Fixing the example above means deriving PartialEq
for Point
. Per default,
deriving PartialEq
will compare all fields for equality, which therefore have
to implement PartialEq
themselves.
#[derive(Copy, Clone, PartialEq)]
struct Point(i32, i32);
fn main() {
let a = Point(1, 2);
let b = Point(1, 2);
let c = Point(2, 2);
let d = a;
println!("{}", a == b); // true
println!("{}", a.eq(&b)); // true
println!("{}", a.eq(&c)); // false
println!("{}", a.eq(&d)); // true
}
See also:
Eq
for a stricter version ofPartialEq
.
Generics
Generics in Java provide a way to create definitions for types and methods that can be parameterized over other types. This improves code reuse, type-safety and performance (e.g. avoid run-time casts).
Consider the following example of a generic Point
type in Java:
public record Point<T>(T x, T y) {}
// creating instances of Point
Point<Integer> p1 = new Point<>(10, 12);
Point<Double> p2 = new Point<>(10.5, 12.4);
Here's the Rust equivalent:
struct Point<T> {
x: T,
y: T,
}
fn main() {
// creating instances of Point struct
let p1 = Point { x: 10, y: 12, };
let p2 = Point { x: 10.5, y: 12.4, };
}
Here's another example of a generic type in Java:
record Rectangle<T>(T length, T width) {
// Accessor method. Not necessary, added for illustration purposes
public T length() {
return length;
}
// Accessor method. Not necessary, added for illustration purposes
public T width() {
return width;
}
}
Rectangle2<Integer> rect1 = new Rectangle2<>(10, 5);
Rectangle2<Double> rect2 = new Rectangle2<>(12.4, 6.2);
System.out.println("Length of rect1 is: " + rect1.length()); // prints: Length of rect1 is: 10
System.out.println("Length of rect2 is: " + rect2.length());// prints: Length of rect2 is: 12.4
Rust also has generics as shown by the equivalent of the above:
#![allow(dead_code)]
struct Rectangle<T> {
length: T,
width: T,
}
impl<T> Rectangle<T> {
pub fn length(&self) -> &T {
&self.length
}
pub fn width(&self) -> &T {
&self.width
}
}
fn main() {
let rect1 = Rectangle { length: 10, width: 5 };
let rect2 = Rectangle { length: 12.4, width: 6.2 };
println!("Length of rect1 is: {}", rect1.length()); // prints: Length of rect1 is: 10
println!("Length of rect2 is: {}", rect2.length()); // prints: Length of rect2 is: 12.4
}
See also:
Bounded type parameters
In Java, bounded type parameters are used to specify generic types with restrictions related to inheritance hierarchies.
Consider the following example of a generic type that adds a timestamp to any value:
import java.time.Instant;
record Timestamped<T extends Comparable<T>>(T value, Instant timestamp)
implements Comparable<Timestamped<T>> {
public Timestamped(T value) {
this(value, Instant.now());
}
@Override
public int compareTo(Timestamped<T> o) {
Timestamped<? extends Comparable<T>> that = o;
return value.compareTo((T) that.value);
}
}
Timestamped<String> timestamped1 = new Timestamped<>("Hello");
Timestamped<String> timestamped2 = new Timestamped<>("Hello", Instant.now());
Timestamped<String> timestamped3 = new Timestamped<>("Haha", Instant.now());
Timestamped<String> timestamped4 = new Timestamped<>( "House", Instant.now());
System.out.println(timestamped1.compareTo(timestamped2)); // prints: 0
System.out.println(timestamped1.compareTo(timestamped3)); // prints: 4 (+ve integer)
System.out.println(timestamped1.compareTo(timestamped4)); // prints: -10 (-ve integer)
The same result can be achieved in Rust as shown below:
#![allow(dead_code)]
use std::time::Instant;
use std::cmp::Ordering;
#[derive(Debug)]
struct Timestamped<T: Ord> {
value: T,
timestamp: Instant
}
impl<T: Ord> Timestamped<T> {
fn new(value: T) -> Self {
Self { value, timestamp: Instant::now() }
}
}
impl<T: Ord> Ord for Timestamped<T> {
fn cmp(&self, other: &Self) -> Ordering {
self.value.cmp(&other.value)
}
}
impl<T> PartialOrd for Timestamped<T>
where T: PartialEq + Ord {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl<T> PartialEq for Timestamped<T>
where T: PartialEq + Ord {
fn eq(&self, other: &Self) -> bool {
self.value == other.value
}
}
impl<T> Eq for Timestamped<T> where T: PartialEq + Ord {}
fn main() {
let timestamped1 = Timestamped::new("Hello");
let timestamped2 = Timestamped { value: "Hello", timestamp: Instant::now() };
let timestamped3 = Timestamped { value: "Haha", timestamp: Instant::now() };
let timestamped4 = Timestamped { value: "House", timestamp: Instant::now() };
println!("{:?}", timestamped1.cmp(×tamped2)); // prints: Equal
println!("{:?}", timestamped1.cmp(×tamped3)); // prints: Greater
println!("{:?}", timestamped1.cmp(×tamped4)); // prints: Less
}
Generic type constraints in Rust (also called trait bounds), can be declared using the colon syntax (e.g. T: Ord
) or the where
clause (e.g. where T: PartialEq + Ord
).
See also:
Type erasure and monomorphization
During compilation, the Java compiler erases the parameterized data type in the byte code (replacing the parameterized type with its concrete equivalent). After compilation, the JVM does not see any distinction between the parameterized
and the raw
data types. This phenomenon is called type erasure
.
The Rust compiler on the other hand performs monomorphization
of the code that uses generics. Monomorphization is the process of turning generic code into specific code by filling in the concrete types that are used after compilation. This ensures that the resulting code is as performant as code written without using generic types.
Polymorphism
Rust does not support classes and sub-classing; therefore polymorphism can't be achieved in an identical manner to Java.
In Rust, polymorphism is mainly achieved via virtual dispatch using trait objects.
See also:
Inheritance
Rust does not provide (class-based) inheritance or type hierarchies as in Java. A way to provide shared behavior between structs is via making use of traits. However, similar to interface inheritance in Java, Rust allows defining relationships between traits by using supertraits.
See also:
Error Handling
In Java, an exception is a type that inherits from the Throwable
class. This class is the superclass of all errors and exceptions in the Java language. Exceptions are thrown if a problem occurs in a code section. A thrown exception is passed up the stack until the application handles it or the program terminates.
Java distinguishes between checked and unchecked exceptions. The Throwable
class has two immmediate descendant classes: Error
and Exception
. Subclasses of the Error
class are fatal errors and are called unchecked exceptions, and are not required to be caught. Subclasses of the Exception
class (excluding RuntimeException
) are called
checked exceptions and have to be handled in your code.
Rust does not have exceptions, but distinguishes between recoverable and
unrecoverable errors instead. A recoverable error represents a problem that
should be reported, but for which the program continues. Results of operations
that can fail with recoverable errors are of type Result<T, E>
,
where E
is the type of the error variant. The panic!
macro stops
execution when the program encounters an unrecoverable error. An unrecoverable
error is always a symptom of a bug.
Custom error types
In Java, you can create your own custom exceptions by extending the Exception
class (or one of the classes from the Throwable
hierarchy). Here's a simple example:
public class EmployeeListNotFoundException extends Exception {
public EmployeeListNotFoundException() { }
public EmployeeListNotFoundException(String message) {
super(message);
}
public EmployeeListNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}
In Rust, one can implement the basic expectations for error values by
implementing the Error
trait. The minimal user-defined error
implementation in Rust is:
#[derive(Debug)]
pub struct EmployeeListNotFound;
impl std::fmt::Display for EmployeeListNotFound {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Could not find employee list.")
}
}
impl std::error::Error for EmployeeListNotFound {}
The equivalent to Java's Throwable.getCause()
method is the Error::source()
method in Rust. However, it is not required to provide an implementation for Error::source()
, the blanket (default) implementation returns a None
.
Raising exceptions
To raise an exception in Java, throw an instance of the Exception
class or any of the subclasses in its hierarchy:
static void throwIfNegative(int value) {
if (value < 0) {
throw new IllegalArgumentException("Illegal argument value");
}
}
For recoverable errors in Rust, return an Ok
or Err
variant from a method:
fn error_if_negative(value: i32) -> Result<(), &'static str> {
if value < 0 {
Err("Illegal argument value. (Parameter 'value')")
} else {
Ok(())
}
}
The panic!
macro creates unrecoverable errors:
fn panic_if_negative(value: i32) {
if value < 0 {
panic!("Illegal argument value. (Parameter 'value')")
}
}
Error propagation
In Java, exceptions are passed up the stack until they are handled or the program terminates.
In Rust, unrecoverable errors behave similarly, but handling them is uncommon. Recoverable errors, however, need to be propagated and handled explicitly. Their presence is always indicated by the Rust function or method signature.
Catching an exception allows you to take action based on the presence or absence of an error in Java:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
static void write() {
try {
Files.write(
Paths.get("file.txt"),
"content to write".getBytes(),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING
);
} catch (IOException e) {
System.out.println("Writing to file failed.");
}
}
In Rust, this is roughly equivalent to:
fn write() {
match std::fs::File::create("file.txt")
.and_then(|mut file| std::io::Write::write_all(&mut file, b"content to write"))
{
Ok(_) => {}
Err(_) => println!("Writing to file failed."),
};
}
Frequently, recoverable errors need only be propagated instead of being handled.
For this, the method signature needs to be compatible with the types of the
propagated error. The ?
operator propagates errors
ergonomically:
fn write() -> Result<(), std::io::Error> {
let mut file = std::fs::File::create("file.txt")?;
std::io::Write::write_all(&mut file, b"content to write")?;
Ok(())
}
Note: To propagate an error with the question mark operator, the error
implementations need to be compatible, as described in a shortcut for
propagating errors. The most general
"compatible" error type is the error trait object Box<dyn Error>
.
Stack traces
Throwing an unhandled exception in Java will cause the runtime to print a stack trace that allows debugging the problem with additional context.
For unrecoverable errors in Rust, panic!
backtraces offer a
similar behavior.
Recoverable errors in stable Rust do not yet support Backtraces, but it is currently supported in experimental Rust when using the provide method.
Nullability and Optionality
Java has the Optional<T>
1 utility class which represents a container object that may contain some value or null. In Java, null
is often used to represent a value that is missing, absent or logically uninitialized. Here's an example of how we can use the Optional class:
Optional<String> some = Optional.ofNullable("John");
Optional<String> none = Optional.ofNullable(null);
System.out.println(some.isPresent()); // true
System.out.println(some.get()); // prints John
System.out.println(none.isEmpty()); // true
Rust has no null
. Optional or missing values are represented by the Option<T>
type. The equivalent of the Java code above in Rust would be:
let some: Option<&str> = Some("John");
let none: Option<&str> = None;
assert_eq!(some.is_some(), true); // ok
println!("{}", some.unwrap()); // prints John
assert_eq!(none.is_none(), true); // ok
Control flow with optionality
In Java, you may have used if
/else
statements to control the flow when using nullable values. For example:
String name = some Name object that may be null...
if (name != null) {
// do something with the name variable e.g. print it
System.out.println(name);
} else {
// deal with the null case or print a default name
System.out.println("John");
}
We can rewrite the code above to use the Optional class as follows:
String name = some Name object that may be null...
Optional<String> optionalName = Optional.ofNullable(name);
if (optionalName.isPresent()) {
// do something with the name
System.out.println(optionalName.get());
} else {
// deal with the empty Optional or print a default name
System.out.println("John");
}
In Rust, we can use pattern matching to achieve the same behavior:
let name: Option<&str> = Some("Arya");
match name {
Some(name) => println!("{}", name),
None => println!("John") // if None, print default name instead
}
We can also make the Java code even more succinct:
String name = some Name object that may be null...
String nameToPrint = Optional.ofNullable(name).orElse("John");
System.out.println(nameToPrint);
And the Rust code can be rewritten as below:
let name: Option<&str> = Some("Arya");
let name_to_print = name.unwrap_or("John"); // if name is None, use default value
println!("{}", name_to_print);
Note: If the default value is expensive to compute, you can use unwrap_or_else
instead. It takes a closure as an argument, which allows you to lazily initialize the default value.
If we only care about the presence of a value (rather than its absence), then we can write code like this in Java:
Optional<String> optionalName = Optional.of("Arya");
optionalName.ifPresent(name -> System.out.println("The name is " + name)); // prints: The name is Arya
The equivalent in Rust can be achieved using if let
:
let name = Some("Arya");
if let Some(name) = name {
println!("The name is {}", name); // prints: The name is Arya
}
The Optional
class was introduced in Java 8.
Conversion and Casting
Both Java and Rust are statically-typed at compile time. Hence, after a variable is declared, assigning a value of a different type (unless it's implicitly convertible to the target type) to the variable is prohibited. There are several ways to convert types in Java that have an equivalent in Rust.
Implicit conversions
Implicit conversions exist in Java as well as in Rust (called type coercions). Consider the following example:
int intNumber = 1;
long longNumber = intNumber; // `int` is implicitly converted to `long`
Rust is much more restrictive with respect to which type coercions are allowed:
let int_number: i32 = 1;
let long_number: i64 = int_number; // error: expected `i64`, found `i32`
An example for a valid implicit conversion using subtyping is:
fn bar<'a>() {
let s: &'static str = "hi";
let t: &'a str = s;
}
See also:
Explicit conversions
If converting could cause a loss of information, Java requires explicit conversions using a casting expression:
double a = 1.2;
int b = (int) a;
Rust does not provide coercion between primitive types, but instead uses
explicit conversion using the as
keyword (casting).
Casting in Rust will not cause a panic.
let int_number: i32 = 1;
let long_number: i64 = int_number as _;
Custom conversion
In Rust, the standard library contains an abstraction for converting a value
into a different type, in form of the From
trait and its
reciprocal, Into
. When implementing From
for a type, a default
implementation for Into
is automatically provided (called blanket
implementation in Rust). The following example illustrates two of such type
conversions:
fn main() {
let my_id = MyId("id".into()); // `into()` is implemented automatically due to the `From<&str>` trait implementation for `String`.
println!("{}", String::from(my_id)); // This uses the `From<MyId>` implementation for `String`.
}
struct MyId(String);
impl From<MyId> for String {
fn from(MyId(value): MyId) -> Self {
value
}
}
See also:
Annotations
In Java, annotations are a specialized kind of interface that allows you to add custom metadata about your code.
Here's an example of a method that's annotated with @Test
to indicate that it's a test method:
@Test
public void someTestMethod() {
// arrange
// act
// assert
}
The equivalent of annotations in Rust are called attributes. Here's a test method that is annotated with the #[test]
attribute:
#[test]
fn some_test_method() {
// arrange
// act
// assert
}
See also:
Smart Pointers
Rust (like C++) has smart pointer types. Unlike a normal pointer (a memory address that points to some other data), a smart pointer is a data structure that not only acts as a pointer, but also has additional capabilities (and metadata).
Different smart pointers are defined in the Rust standard library. Some of the most common are:
Box<T>
: for allocating values on the heapRc<T>
/Arc<T>
: reference counting type that enables multiple value ownershipRefCell<T>
: a type that enforces borrowing rules at runtime (and enables interior mutability)
We are going to briefly look at the Box<T>
type in this section. For more about smart pointers in Rust, see the Smart Pointers chapter of the Rust book.
Using Box<T>
to store data on the heap
Consider this record type in Java:
record Point(double x, double y) {}
// create an instance of Point
Point p1 = new Point(10.5, 12.4);
Point p2 = new Point(10.5, 12.4);
Point p3 = new Point(14.2, 8.2);
System.out.println(p1); // prints: Point[x=10.5, y=12.4]
System.out.println(p1 == p2); // false
System.out.println(p1.equals(p2)); // true
System.out.println(p1.equals(p3)); // false
The record class is a reference type in Java. Therefore, variable p1
is a reference (pointer) to the object that is created using the new
keyword. While the reference itself is stored on the stack, the object that it points to is allocated on the heap.
In Rust, structs are allocated on the stack by default. If you want to store the struct on the heap instead, then you'll have to box
it (i.e. wrap it in a Box<T>
smart pointer):
#![allow(dead_code)]
#[derive(Debug, PartialEq)]
struct Point {
x: f64,
y: f64,
}
fn main() {
// create an instance of Point struct
let p1 = Point { x: 10.5, y: 12.4 }; // lives on the stack
let p2 = Point { x: 10.5, y: 12.4 };
let p3 = Box::new(Point { x: 10.5, y: 12.4 }); // allocate on the heap
let p4 = Box::new(p1); // exactly the same as the line above
println!("{:?}", p1); // prints: Point { x: 10.5, y: 12.4 }
assert_eq!(p1, p2); // ok
assert_eq!(p1, p3); // fails
assert_eq!(p1, *p3); // ok, use the dereference operator (*) to get to the value stored on the heap
assert_eq!(p3, p4); // ok
}
See also:
- Smart Pointers chapter of the Rust book.
Documentation Comments
Java provides a mechanism to document an API using a comment syntax that uses HTML text. The JDK comes with the javadoc
tool that can be used to compile/generate HTML output for the doc comments.
Here's a simple example of a doc comment in Java:
/**
* This is a doc comment for <b>MyClass</b>.
*
*/
public class MyClass {}
In Rust, doc comments provide the equivalent to Java doc comments.
Documentation comments in Rust use Markdown syntax. rustdoc
is the
documentation compiler for Rust code and is usually invoked through cargo doc
, which compiles the comments into documentation.
Here's a simple example of a doc comment in Rust:
/// This is a doc comment for `MyStruct`.
struct MyStruct;
See also:
Memory Management
Like Java (and other languages with automatic memory management), Rust has memory-safety to avoid a whole class of bugs
related to memory access, and which end up being the source of many security
vulnerabilities in software. However, Rust can guarantee memory-safety at
compile-time; there is no run-time (like the JVM) making checks. The one
exception here is array bound checks that are done by the compiled code at
run-time. But unlike Java, it is also possible to write unsafe code in Rust, and the language has the unsafe
keyword to mark
functions and blocks of code where memory-safety is no longer guaranteed.
Rust has no garbage collector (GC). All memory management is entirely the responsibility of the developer. That said, safe Rust has rules around ownership that ensure memory is freed as soon as it's no longer in use (e.g. when leaving the scope of a block or a function). The compiler does a tremendous job, through (compile-time) static analysis, of helping manage that memory through ownership rules. If violated, the compiler rejects the code with a compilation error.
In the JVM, there is no concept of ownership of memory beyond the GC roots (static fields, local variables on a thread's stack, CPU registers, handles, etc.). It is the GC that walks from the roots during a collection to detemine all memory in use by following references and purging the rest. When designing types and writing code, a Java developer can remain oblivious to ownership, memory management and even how the garbage collector works for the most part, except when performance-sensitive code requires paying attention to the amount and rate at which objects are being allocated on the heap. In contrast, Rust's ownership rules require the developer to explicitly think and express ownership at all times and it impacts everything from the design of functions, types, data structures to how the code is written. On top of that, Rust has strict rules about how data is used such that it can identify at compile-time, data race conditions as well as corruption issues (requiring thread-safety) that could potentially occur at run-time.
This section focuses on the following important concepts related to memory management in Rust:
Ownership
Ownership is arguably one of Rust's most distinctive features. It allows Rust to make memory safety guarantees without needing a garbage collector.
In Rust, there can only be one owner of some memory, be that on the stack or heap, at any given time. Rust defines ownership rules that are enforced at compile time:
- Each value in Rust has a variable that's called it's owner.
- There can be only one owner at a time.
- When the owner goes out of scope, the value will be dropped.
The Rust compiler assigns lifetimes and tracks ownership. It is possible to pass or yield ownership, which is called moving in Rust.
See also:
-
Ownership in Rust.
Consider the following Java code that works without any errors:
record Point(int x, int y) {}
Point p1 = new Point(12, 10);
Point p2 = p1;
System.out.println(p1.x() + ", " + p1.y()); // prints: 12, 10
System.out.println(p2.x() + ", " + p2.y()); // prints: 12, 10
Now, let's look at the Rust version (fails with an error):
#![allow(dead_code, unused_variables)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 12, y: 10 }; // point owned by p1
let p2 = p1; // ownership of point moved to p2 here
println!("{}, {}", p1.x, p1.y); // doesn't work, compiler error!
println!("{}, {}", p2.x, p2.y); // works fine, prints: 12, 10
}
The first statement in main
will allocate Point
and that memory will be
owned by p1
. In the second statement, the ownership is moved from p1
to p2
and p1
can no longer be used because it no longer owns anything or represents valid memory. The statement that tries to print the fields of the point via p1
will fail compilation.
Borrowing
One way of making the code compile is by letting p2
borrow the value of p1
instead of taking ownership, as shown below. The ampersand (&
) indicates that p2
takes a
reference to the value of p1
.
fn main() {
let p1 = Point { x: 12, y: 10 }; // point owned by p1
let p2 = &p1; // p2 "borrows" point, doen't take ownership
println!("{}, {}", p1.x, p1.y); // works fine, prints: 12, 10
println!("{}, {}", p2.x, p2.y); // works fine, prints: 12, 10
}
Cloning
Another alternative would be to clone p1
:
#[derive(Clone)] // this is required for cloning to work
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 12, y: 10 }; // point owned by p1
let p2 = p1.clone(); // clone point instead of taking ownership
println!("{}, {}", p1.x, p1.y); // prints: 12, 10
println!("{}, {}", p2.x, p2.y); // prints: 12, 10
}
Note that the
Point
struct needs to implement/derive theClone
trait in order for cloning to work.
Variable Scope
Let's look at this Rust code again:
fn main() {
let p1 = Point { x: 12, y: 10 }; // point owned by p1
let p2 = p1; // p2 owns the point now
println!("{}, {}", p2.x, p2.y); // ok, uses p2
} // point behind p2 is dropped
When main
exits, p1
and p2
will go out of scope. The memory
behind p2
will be released by virtue of the stack returning to its state
prior to main
being called. In Rust, one says that the point behind p2
was dropped. However, note that since p1
yielded its ownership of the point to p2
, there is nothing to drop when p1
goes out of scope.
A struct
in Rust can define code to execute when an instance is dropped by
implementing the Drop
trait.
The rough equivalent of dropping in Java would be an object finalizer: the finalize()
method1 provided by the root Object
class, that is called before the object is garbage collected. While a finalizer would be called automatically by the GC at some future point, dropping in Rust is always instantaneous and deterministic; that is, it happens at the point the compiler has determined that an instance has no owner
based on scopes and lifetimes.
In Java, the equivalent of the Drop
trait is the AutoCloseable
interface, and is implemented by types to release any unmanaged resources or memory they hold. Deterministic disposal is not enforced or guaranteed, but the try-with-resources
statement in Java is typically used to scope an instance of an auto-closeable type such that it gets disposed deterministically, at the end of the try-with-resources
statement's block.
In Java, references are shared freely without much thought so the idea
of a single owner and yielding/moving ownership may seem very limiting in
Rust, but it is possible to have shared ownership in Rust using the smart
pointer type Rc
; it adds reference-counting. Each time the smart pointer is cloned, the reference count is incremented. When the
clone drops, the reference count is decremented. The actual instance behind
the smart pointer is dropped when the reference count reaches zero.
These points are illustrated by the following example that builds on the previous:
#![allow(dead_code, unused_variables)]
use std::rc::Rc;
struct Point {
x: i32,
y: i32,
}
impl Drop for Point {
fn drop(&mut self) {
println!("Point dropped!");
}
}
fn main() {
let p1 = Rc::new(Point { x: 12, y: 10 });
let p2 = Rc::clone(&p1); // share with p2
println!("p1 = {}, {}", p1.x, p1.y); // okay to use p1
println!("p2 = {}, {}", p2.x, p2.y);
}
// prints:
// p1 = 12, 10
// p2 = 12, 10
// Point dropped!
Note that:
-
Point
implements thedrop
method of theDrop
trait and prints a message when an instance of aPoint
is dropped. -
The point created in
main
is wrapped behind the smart pointerRc
and so the smart pointer owns the point and notp1
. -
p2
gets a clone of the smart pointer that effectively increments the reference count to 2. Unlike the earlier example, wherep2
transferred its ownership of point top2
, bothp1
andp2
own their own distinct clones of the smart pointer, so it is okay to continue to usep1
andp2
. -
The compiler will have determined that
p1
andp2
go out of scope at the end ofmain
and therefore injected calls to drop each. TheDrop
implementation ofRc
will decrement the reference count and also drop what it owns if the reference count has reached zero. When that happens, theDrop
implementation ofPoint
will print the message, “Point dropped!” The fact that the message is printed once demonstrates that only one point was created, shared and dropped.
Rc
is not thread-safe. For shared ownership in a multi-threaded program, the
Rust standard library offers Arc
instead. The Rust language will
prevent the use of Rc
across threads.
In Java, primitive types (like int
and double
) live on the stack and
reference types (like class
, interface
, and record
) are heap-allocated. In Rust, the kind of type (basically enum
or struct
), does not determine where the backing memory will eventually live. By default, it is always on the stack, but just the way Java has the notion of autoboxing of primitive types, the way to allocate a type on the heap is to box it using Box
:
let stack_point = Point { x: 12, y: 10 };
let heap_point = Box::new(Point { x: 12, y: 10 });
Like Rc
and Arc
, Box
is a smart pointer, but unlike Rc
and Arc
, it
exclusively owns the instance behind it. All of these smart pointers allocate
an instance of their type argument T
on the heap.
The new
keyword in Java creates an instance of a type, and while members such
as Box::new
and Rc::new
that you see in the examples may seem to have a
similar purpose, new
has no special designation in Rust. It's merely a
conventional name that is meant to denote a factory. In fact they are called
associated functions of the type, which is Rust's way of saying static
methods.
The finalization
mechanism has been deprecated since Java 9. In modern Java, the preferred approach for resource management is by the use of cleaners, or try-with-resources statement.
References and Lifetimes
Consider this Rust code:
#![allow(dead_code, unused_variables)]
#[derive(Debug, PartialEq)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 12, y: 10 };
let p2 = &p1; // p2 takes a reference to the value of p1
assert_eq!(Point { x: 12, y: 10 }, p1); // ok
assert_eq!(Point { x: 12, y: 10 }, p2); // fails
assert_eq!(Point { x: 12, y: 10 }, *p2); // ok, we use the dereference operator (*) to get the value
}
In Rust, references allow you to refer to some value without taking ownership of it. References are indicated by the use of an ampersand (&
). In order to get to the value that a reference points to, we use the dereference operator (*
).
Consider the following Rectangle
struct and its implementation block:
#[derive(Debug)]
struct Rectangle {
length: i32,
width: i32,
}
impl Rectangle {
pub fn new(length: i32, width: i32) -> Self {
Self { length, width }
}
pub fn length(&self) -> i32 {
self.length
}
pub fn width(&self) -> i32 {
self.width
}
}
Here's a function to calculate the area of a rectangle. Notice that we are passing in the Rectangle
by value:
fn calculate_area(rect: Rectangle) -> i32 {
rect.length() * rect.width()
}
When we exercise the function as shown below, we get an error. Because we are passing
by value, ownership of rect is moved into the calculate_area()
function:
fn main() {
let rect = Rectangle::new(6, 4);
let area_of_rect = calculate_area(rect); // value moved into function
println!("{}", area_of_rect); // prints: 24
println!("{:?}", rect); // won't work, rect no longer available here
}
One (inefficient) solution would be to make Rectangle
cloneable:
#[derive(Debug, Clone)]
struct Rectangle {
length: i32,
width: i32,
}
fn main() {
let rect = Rectangle::new(6, 4);
let area_of_rect = calculate_area(rect.clone());
println!("{}", area_of_rect); // prints: 24
println!("{:?}", rect); // works fine, prints: Rectangle { length: 6, width: 4 }
}
When we are dealing with a lot of objects, then cloning wouldn't be a great solution.
A better approach would be to make the calculate_area()
function take a reference to the Rectangle
(i.e. &Rectangle
) as input. In other words, pass the value by reference.
fn calculate_area(rect: &Rectangle) -> i32 {
rect.length() * rect.width()
}
And now we can use the function as follows:
#[derive(Debug)] // no more Clone
struct Rectangle {
length: i32,
width: i32,
}
fn main() {
let rect = Rectangle::new(6, 4);
let area_of_rect = calculate_area(&rect); // takes a reference as input
println!("{}", area_of_rect); // prints: 24
println!("{:?}", rect); // works fine, prints: Rectangle { length: 6, width: 4 }
}
Finally, let's consider the following case that doesn't compile:
fn main() {
let rect1;
{
let rect2 = Rectangle::new(6, 4);
rect1 = &rect2;
} // rect2 goes out of scope/is dropped here
// rect1, which holds a reference to rect2 is invalid here
println!("{:?}", rect1); // won't work: borrowed value (&rect2) does not live long enough
}
Here's the same code with lifetime annotations:
fn main() {
let rect1; // -----------+--- 'a
// |
{ // |
let rect2 = Rectangle::new(6, 4); // --+-- 'b |
rect1 = &rect2; // | |
} // --+ |
// |
println!("{:?}", rect1); // |
} // -----------+
As you can see, the lifetime of rect2 ('b
) is shorter than that of rect1 ('a
). That's what the error message indicates: borrowed value (&rect2) does not live long enough
. Therefore trying to access rect2 after it's been dropped results in a compile-time error.
The lifetime annotation
'a
is pronounced "tick A".
The Rust compiler has a borrow checker that keeps track of variable lifetimes to determine whether all borrows are valid.
The version below works fine:
fn main() {
let rect2 = Rectangle::new(6, 4); // -----------+--- 'b
// |
let rect1 = &rect2; // --+-- 'a |
// | |
println!("{:?}", rect1); // | |
//---+ |
} // -----------+
See also:
- Lifetimes in Rust.
Resource Management
The previous section on memory management explains the differences between Java and Rust when it comes to GC, ownership and finalizers. It is highly recommended that you read it.
This section is limited to providing an example of a fictional database connection involving a SQL connection to be properly closed/disposed/dropped.
Here's the Java version:
import java.sql.Connection;
public class DatabaseConnection implements AutoCloseable {
final String connectionString;
Connection connection; // the Connection type implements AutoCloseable
public DatabaseConnection(String connectionString) {
this.connectionString = connectionString;
}
@Override
public void close() throws Exception {
// making sure to close the Connection
this.connection.close();
System.out.println("Closing connection: " + this.connectionString);
}
}
// try-with-resources statement
try (
var db1 = new DatabaseConnection("server=A; database=db1");
var db2 = new DatabaseConnection("server=A; database=db2")
) {
// ...code using "db1" and "db2"...
} // "close()" of "db1" and "db2" called here; when their scope ends
And here's the Rust equivalent:
struct DatabaseConnection(&'static str);
impl DatabaseConnection {
// ...functions for using the database connection...
}
impl Drop for DatabaseConnection {
fn drop(&mut self) {
// ...closing connection...
self.close_connection();
// ...printing a message...
println!("Closing connection: {}", self.0)
}
}
fn main() {
let _db1 = DatabaseConnection("Server=A;Database=DB1");
let _db2 = DatabaseConnection("Server=A;Database=DB2");
// ...code for making use of the database connection...
} // "Dispose" of "db1" and "db2" called here; when their scope ends
Threading and Concurrency
The Rust standard library supports threading, synchronization and concurrency. Even though the language itself and the standard library do have basic support for these concepts, a lot of additional functionality is provided by crates and will not be covered in this section.
This section focuses on the following concepts related to threading and concurrency:
Threads
The following table lists approximate mapping of threading types and methods in Java to Rust:
Java | Rust |
---|---|
Thread | std::thread::Thread |
Thread.start | std::thread::spawn |
Thread.join | std::thread::JoinHandle |
Thread.sleep | std::thread::sleep |
synchronized | - |
ReentrantLock | std::sync::Mutex |
ReentrantReadWriteLock | std::sync::RwLock |
Semaphore | - |
Condition | std::sync::Condvar |
java.util.concurrent.atomic.* | std::sync::atomic |
volatile | - |
ThreadLocal | std::thread_local |
Launching a thread and waiting for it to finish works the same way in Java and Rust. Below is a simple Java program that creates a thread and then waits for it to end:
Thread thread = new Thread(() -> {
System.out.println("Hello from a thread!"); });
thread.start();
thread.join(); // wait for thread to finish
The same code in Rust looks as follows:
use std::thread;
fn main() {
let thread = thread::spawn(|| println!("Hello from a thread!"));
thread.join().unwrap(); // wait for thread to finish
}
Creating and initializing a thread object and starting a thread are two
different actions in Java whereas in Rust both happen at the same time with
thread::spawn
.
Java 21 introduced the Thread.Builder API for creating and starting both platform and virtual threads. Platform threads are the traditional threads in Java that are thin wrappers around OS threads - the thread that we created above is an example of a platform thread. Virtual threads on the other hand are lightweight threads that are not directly tied to OS threads.
Here's how we can use the thread builder API to create a platform thread in Java:
Thread platformThread = Thread.ofPlatform().unstarted(() -> {
System.out.println("Hello from a platform thread!"); });
platformThread.start();
platformThread.join();
And here's how we can create a virtual thread:
Thread virtualThread = Thread.ofVirtual().unstarted(() -> {
System.out.println("Hello from a virtual thread!"); });
virtualThread.start();
virtualThread.join();
Note: Rust does not have support for virtual threads.
Working with thread pools
In Java, we can create a thread pool using the Executor framework.
Consider the following Java example:
static void concurrentProcessing() throws ExecutionException, InterruptedException {
// Using Callable so a value can be returned after processing by a thread
Callable<Integer> sportsNews = () -> {
for (int i = 0; i < 10; i++) {
Thread.sleep(1000); // sleep for 1 second
System.out.println("Manchester United is winning " + i);
}
// Just return some random number to illustrate return
return 42;
};
Callable<Integer> leaguePoints = () -> {
for (int i = 0; i < 10; i++) {
Thread.sleep(700); // sleep for 700 milliseconds
int points = 40 + i;
System.out.println("Manchester has " + points + " league points");
}
// Just return some random number
return 24;
};
// A placeholder for Future objects
List<Future<Integer>> futures = new ArrayList<>();
// A temporary storage for results returned by threads
List<Integer> results = new ArrayList<>();
try (
final ExecutorService service = Executors.newFixedThreadPool(2); // Create a pool of 2 threads
) {
futures.add(service.submit(sportsNews));
futures.add(service.submit(leaguePoints));
for (var future : futures) {
results.add(future.get());
}
}
for (Integer result : results) {
System.out.println("Got the result: " + result);
}
}
public static void main(String[] args) {
try {
concurrentProcessing();
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
Here's the output of running the Java code above:
Manchester has 40 league points
Manchester United is winning 0
Manchester has 41 league points
Manchester United is winning 1
Manchester has 42 league points
Manchester has 43 league points
Manchester United is winning 2
Manchester has 44 league points
Manchester United is winning 3
Manchester has 45 league points
Manchester has 46 league points
Manchester United is winning 4
Manchester has 47 league points
Manchester United is winning 5
Manchester has 48 league points
Manchester United is winning 6
Manchester has 49 league points
Manchester United is winning 7
Manchester United is winning 8
Manchester United is winning 9
Got the result: 42
Got the result: 24
Note: Your output might look slighly different.
Here's the Rust version of the code. We are using the Rayon library to create a thread pool.
use std::{thread, time};
use std::sync::Mutex;
fn sports_news() -> i32 {
for i in 0..10 {
let seconds = time::Duration::from_millis(1000);
thread::sleep(seconds); // sleep for 1 second
println!("Manchester United is winning {}", i);
}
// Just return some number to illustrate return
return 42;
}
fn league_points() -> i32 {
for i in 0..10 {
let seconds = time::Duration::from_millis(700);
thread::sleep(seconds); // sleep for 700 milliseconds
let points = 40 + i;
println!("Manchester has {} league points", points);
}
// Just return some number
return 24;
}
fn concurrent_processing() {
// A temporary storage for results returned by threads
let result_vec = Mutex::new(Vec::new());
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(2) // Create a pool of 2 threads
.build()
.unwrap();
pool.install(|| {
rayon::scope(|s| {
let result_vec = &result_vec;
// spawn thread to process sports news
s.spawn(move |_| {
let result1 = sports_news();
result_vec.lock().unwrap().push(result1);
});
// spawn thread to process league points
s.spawn(move |_| {
let result2 = league_points();
result_vec.lock().unwrap().push(result2);
});
});
});
for result in result_vec.lock().unwrap().iter() {
println!("Got the result: {:?}", result);
}
}
fn main() {
concurrent_processing();
}
Here's the output of the Rust code above:
Manchester has 40 league points
Manchester United is winning 0
Manchester has 41 league points
Manchester United is winning 1
Manchester has 42 league points
Manchester has 43 league points
Manchester United is winning 2
Manchester has 44 league points
Manchester United is winning 3
Manchester has 45 league points
Manchester has 46 league points
Manchester United is winning 4
Manchester has 47 league points
Manchester United is winning 5
Manchester has 48 league points
Manchester United is winning 6
Manchester has 49 league points
Manchester United is winning 7
Manchester United is winning 8
Manchester United is winning 9
Got the result: 24
Got the result: 42
Synchronization
When data is shared between threads, one needs to synchronize read-write
access to the data in order to avoid corruption. Java offers the synchronized
keyword as a synchronization primitive. When we use a synchronized block, Java internally uses a monitor, also known as a monitor lock to provide synchronization:
public class ExampleClass {
static int data = 0;
public static void main(String[] args) throws InterruptedException {
var dataLock = new Object(); // declare a monitor lock object
var threads = new ArrayList<Thread>();
for (var i = 0; i < 10; i++) {
var thread = new Thread(() -> {
for (var j = 0; j < 1000; j++) {
synchronized (dataLock) {
data++;
}
}
});
threads.add(thread);
thread.start();
}
for (var thread : threads) {
thread.join();
}
System.out.println(data); // prints: 10000
}
}
In Rust, one must make explicit use of concurrency structures like Mutex
:
use std::thread;
use std::sync::{Arc, Mutex};
fn main() {
let data = Arc::new(Mutex::new(0)); // (1)
let mut threads = vec![];
for _ in 0..10 {
let data = Arc::clone(&data); // (2)
let thread = thread::spawn(move || { // (3)
for _ in 0..1000 {
let mut data = data.lock().unwrap();
*data += 1; // (4)
}
});
threads.push(thread);
}
for thread in threads {
thread.join().unwrap();
}
println!("{}", data.lock().unwrap()); // prints: 10000
}
A few things to note:
-
Since the ownership of the
Mutex
instance and in turn the data it guards will be shared by multiple threads, it is wrapped in anArc
(1).Arc
provides atomic reference counting, which increments each time it is cloned (2) and decrements each time it is dropped. When the count reaches zero, the mutex and in turn the data it guards are dropped. -
The closure instance for each thread receives ownership (3) of the cloned reference (2).
-
The
move
keyword (3) is required to move or pass the ownership ofdata
(2) to the closure for the thread. -
The pointer-like code that is
*data += 1
(4), is not some unsafe pointer access even if it looks like it. It's updating the data wrapped in the mutex guard.
Unlike the Java version, where one can render it thread-unsafe by commenting out
the synchronized
statement, the Rust version will refuse to compile if it's changed
in any way (e.g. by commenting out parts that renders it thread-unsafe). This
demonstrates that writing thread-safe code is the developer's responsibility
in Java, by careful use of synchronized structures, whereas in Rust, one
can rely on the compiler to enforce thread-safety.
The compiler is able to help because data structures in Rust are marked by
special traits: Sync
and Send
. Sync
indicates that references to a type's instances are safe to share between threads. Send
indicates that it's safe to send instances of a type across thread boundaries. For more information, see the “Fearless Concurrency” chapter of the Rust book.
Producer-Consumer Pattern
The producer-consumer pattern is a very common pattern for distributing work between threads where data is passed from producing threads to consuming threads without the need for sharing or locking. Java provides support for this pattern. The
java.util.concurrent
package provides the BlockingQueue
interface that can be used to implement this pattern:
public class ExampleClass {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> messages = new LinkedBlockingQueue<>();
AtomicBoolean isComplete = new AtomicBoolean(false);
Thread producer = new Thread(() -> {
try {
for (int i = 1; i < 10; i++) {
messages.put("Message #" + i); // add a message to the queue
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
isComplete.set(true);
}
});
producer.start();
// Main thread is the consumer here
while (!isComplete.get() || !messages.isEmpty()) {
try {
String message = messages.take(); // get a message from the queue
System.out.println("The message received is: " + message);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
producer.join();
}
}
The output of the code above looks as follows:
The message received is: Message #1
The message received is: Message #2
The message received is: Message #3
The message received is: Message #4
The message received is: Message #5
The message received is: Message #6
The message received is: Message #7
The message received is: Message #8
The message received is: Message #9
The same result can be achieved in Rust by using channels. The standard library primarily
provides mpsc::channel
, which is a channel that supports multiple producers and a single consumer. A rough translation of the above Java example in Rust would look as follows:
use std::thread;
use std::sync::mpsc;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel(); // create a message channel
let producer = thread::spawn(move || {
for i in 1..10 {
tx.send(format!("Message #{}", i)).unwrap(); // send a message to the channel
}
});
// main thread is the consumer here
for received in rx {
println!("The message received is: {}", received);
}
producer.join().unwrap();
}
The output for the Rust code above is as follows:
The message received is: Message #1
The message received is: Message #2
The message received is: Message #3
The message received is: Message #4
The message received is: Message #5
The message received is: Message #6
The message received is: Message #7
The message received is: Message #8
The message received is: Message #9
Testing
Test organization
Conventionally, Java projects use separate files to host test code, irrespective of the test framework being used and the type of tests being written (unit or integration). The test code therefore lives in a separate file (or package) from the application or library code being tested.
In Rust, it is a lot more conventional for unit tests to be found in a separate test sub-module (conventionally) named tests
, but which is placed in the same source file as the application or library module code that is the subject of the
tests. One advantage of this is that the code/module and its unit tests live side-by-side.
The test sub-module is annotated with the #[cfg(test)]
attribute, which has
the effect that the entire module is (conditionally) compiled and run only
when the cargo test
command is issued.
Within the test sub-modules, test functions are annotated with the #[test]
attribute.
Integration tests are usually in a directory called tests
that sits adjacent
to the src
directory with the unit tests and source. cargo test
compiles
each file in that directory as a separate crate and runs all the methods
annotated with the #[test]
attribute. Since it is understood that integration
tests are in the tests
directory, there is no need to mark the modules in there
with the #[cfg(test)]
attribute.
See also:
-
Test Organization in Rust projects.
Running tests
When using a build tool like Maven in Java, you can run tests using mvn test
.
In Rust, you run tests by using cargo test
.
The default behavior of cargo test
is to run all the tests in parallel, but this can be configured to run consecutively using only a single thread:
cargo test -- --test-threads=1
For more information, see "Running Tests in Parallel or Consecutively".
Assertions
Java users have multiple ways to assert, depending on the framework being used. For example, an assertion in JUnit might look like this:
@Test
public void somethingIsTheRightLength() {
String value = "something";
assertEquals(9, value.length());
}
Rust does not require a separate framework or crate. The standard library comes with built-in macros that are good enough for most assertions in tests:
Below is an example of assert_eq!
in action:
#[test]
fn something_is_the_right_length() {
let value = "something";
assert_eq!(9, value.len());
}
Mocking
When writing tests for a Java application or library, there exist several
frameworks, like Mockito, to mock out the dependencies. There are similar crates for Rust too, like mockall
, that can help with mocking. However, it is also possible to use conditional compilation by making use of the cfg
attribute as a simple means to mocking without needing to rely on external crates or frameworks. The cfg
attribute conditionally includes the code it annotates based on a configuration symbol, such as test
for testing. This is not very different to using DEBUG
to conditionally compile code specifically for debug builds. One downside of this approach is that you can only have one implementation for all tests of the module.
The example below shows mocking of a stand-alone function var_os
from the
standard library that reads and returns the value of an environment variable. It
conditionally imports a mocked version of the var_os
function used by
get_env
. When built with cargo build
or run with cargo run
, the compiled
binary will make use of std::env::var_os
, but cargo test
will instead
import tests::var_os_mock
as var_os
, thus causing get_env
to use the
mocked version during testing:
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.
/// Utility function to read an environment variable and return its value If
/// defined. It fails/panics if the valus is not valid Unicode.
pub fn get_env(key: &str) -> Option<String> {
#[cfg(not(test))] // for regular builds...
use std::env::var_os; // ...import from the standard library
#[cfg(test)] // for test builds...
use tests::var_os_mock as var_os; // ...import mock from test sub-module
let val = var_os(key);
val.map(|s| s.to_str() // get string slice
.unwrap() // panic if not valid Unicode
.to_owned()) // convert to "String"
}
#[cfg(test)]
mod tests {
use std::ffi::*;
use super::*;
pub(crate) fn var_os_mock(key: &str) -> Option<OsString> {
match key {
"FOO" => Some("BAR".into()),
_ => None
}
}
#[test]
fn get_env_when_var_undefined_returns_none() {
assert_eq!(None, get_env("???"));
}
#[test]
fn get_env_when_var_defined_returns_some_value() {
assert_eq!(Some("BAR".to_owned()), get_env("FOO"));
}
}
Code coverage
The Java ecosystem has several tools for analyzing test code coverage. One such popular tool is JaCoCo
. In IDEs like IntelliJ, code coverage tooling is built-in and integrated.
Rust provides built-in code coverage implementations for collecting test code coverage.
There are also plug-ins available for Rust to help with code coverage analysis. It's not seamlessly integrated, but with some manual steps, developers can analyze their code in a visual way.
The combination of Coverage Gutters plug-in for Visual Studio Code and Tarpaulin allows visual analysis of the code coverage in Visual Studio Code. Coverage Gutters requires an LCOV file. Other tools besides Tarpaulin can be used to generate that file.
Once setup, run the following command:
cargo tarpaulin --ignore-tests --out Lcov
This generates an LCOV Code Coverage file. Once Coverage Gutters: Watch
is
enabled, it will be picked up by the Coverage Gutters plug-in, which will show
in-line visual indicators about the line coverage in the source code editor.
Note: The location of the LCOV file is essential. If a workspace (see Project Structure) with multiple packages is present and a LCOV file is generated in the root using
--workspace
, that is the file that is being used - even if there is a file present directly in the root of the package. It is quicker to isolate to the particular package under test rather than generating the LCOV file in the root.
Benchmarking
Running benchmarks in Rust is done via cargo bench
, a specific
command for cargo
which executes all the methods annotated with the
#[bench]
attribute. This attribute is currently unstable and
available only for the nightly channel.
Java users can make use of the Java Microbenchmark Harness (JMH)
tool to write microbenchmarks for their Java code. The equivalent of JMH
for Rust is a crate named
Criterion
.
As per its documentation, Criterion
collects and stores
statistical information from run to run and can automatically detect performance
regressions as well as measuring optimizations.
With Criterion
, it is possible to use the #[bench]
attribute without moving to
the nightly channel.
It is possible to integrate benchmark results with the GitHub Action for Continuous Benchmarking. Criterion
, in fact, supports multiple output formats, amongst which there is also the bencher
format, mimicking the nightly libtest
benchmarks and compatible with the mentioned action.
Logging and Tracing
Java comes with a logging API, which is ideal for simple use-cases. Apart from the logging API, there are other logging frameworks for Java, like Log4J
and
Logback
. The Simple Logging Facade for Java (SLF4J)
provides an abstraction layer that allows you to easily switch logging frameworks if needed.
In Java, a minimal example for structured logging using the logging API could look like this:
import java.util.logging.Logger;
public class ExampleLogger {
private static final Logger LOGGER = Logger.getLogger(ExampleLogger.class.getName());
public static void main(String[] args) {
LOGGER.info("Printing Hello World");
System.out.println("Hello World");
}
}
In Rust, a lightweight logging facade is provided by log. This can be used for relatively simple logging use-cases.
For something with more features like some of the Java logging frameworks, Tokio offers tracing
. tracing
is a framework for instrumenting Rust applications to collect structured, event-based diagnostic information. tracing_subscriber
can be used to implement and compose tracing
subscribers. The same structured logging example from above with tracing
and tracing_subscriber
looks like this:
fn main() {
// install global default ("console") collector.
tracing_subscriber::fmt().init();
tracing::info!("Printing Hello World");
println!("Hello World");
}
OpenTelemetry offers a collection of tools, APIs, and SDKs used to instrument, generate, collect, and export telemetry data based on the OpenTelemetry specification. At the time of writing, the OpenTelemetry Logging API is not yet stable and the Rust implementation does not yet support logging, but the tracing API is supported.
Environment and Configuration
Accessing environment variables
Java provides access to environment variables via the System.getenv
method. This method retrieves the value of an environment variable at runtime.
final String name = "EXAMPLE_ENVIRONMENT_VARIABLE";
String value = System.getenv(name);
if (value == null || value.isEmpty()) {
System.out.println("Environment variable '" + name + "' not set.");
} else {
System.out.println("Environment variable '" + name + "' set to '" + value + "'.");
}
Rust provides the same functionality of accessing an environment variable at
runtime via the var
and var_os
functions from the std::env
module.
var
function returns a Result<String, VarError>
, either returning the
variable if set or returning an error if the variable is not set or it is not
valid Unicode.
var_os
has a different signature giving back an Option<OsString>
, either
returning some value if the variable is set, or returning None if the variable
is not set. An OsString
is not required to be valid Unicode.
use std::env;
fn main() {
let key = "ExampleEnvironmentVariable";
match env::var(key) {
Ok(val) => println!("{key}: {val:?}"),
Err(e) => println!("couldn't interpret {key}: {e}"),
}
}
use std::env;
fn main() {
let key = "ExampleEnvironmentVariable";
match env::var_os(key) {
Some(val) => println!("{key}: {val:?}"),
None => println!("{key} not defined in the enviroment"),
}
}
Rust also provides the functionality for accessing an environment variable at
compile time. The env!
macro from std::env
expands the value of the variable
at compile time, returning a &'static str
. If the variable is not set, an
error is emitted.
use std::env;
fn main() {
let example_env_variable = env!("ExampleEnvironmentVariable");
println!("{example_env_variable}");
}
In Java, compile time access to environment variables can be achieved, but in a less straightforward way, using Reflection API. Generally this is not recommended in modern Java.
Configuration
Java does not have built-in support for configuration management. In order to work with configuration, we need to use third-party libraries, such as the Apache Commons Configuration library.
Here's the Maven dependency for the Apache Commons Configuration library that we can add to our pom.xml
file:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-configuration2</artifactId>
<version>2.11.0</version>
</dependency>
And here's how we can make use of the library:
import org.apache.commons.configuration2.Configuration;
import org.apache.commons.configuration2.builder.fluent.Configurations;
import org.apache.commons.configuration2.ex.ConfigurationException;
public class ExampleClass {
public static void main(String[] args) {
Configurations configs = new Configurations();
try {
// Load environment variables
Configuration configuration = configs.systemEnvironment();
// Retrieve the value of the environment variable "ExampleEnvVariable"
String exampleEnvVariable = configuration.getString("ExampleEnvVariable");
System.out.println(exampleEnvVariable);
} catch (ConfigurationException e) {
e.printStackTrace();
}
}
}
A similar configuration experience in Rust is available via use of third-party crates such as figment or config.
See the following example making use of the config crate:
use config::{Config, Environment};
fn main() {
let builder = Config::builder().add_source(Environment::default());
match builder.build() {
Ok(config) => {
match config.get_string("example_env_variable") {
Ok(v) => println!("{v}"),
Err(e) => println!("{e}")
}
},
Err(_) => {
// something went wrong
}
}
}
Build Tools
The two popular build tools in the Java ecosystem are Maven and Gradle. Rust has Cargo (cargo
), which is both a build tool and a package manager.
As an example, to compile/build a project using Maven you would use mvn compile
. The equivalent in Rust using cargo would be cargo build
.
Building
To build an executable for a Java project, you can use mvn package
, which will compile the project sources into a single executable jar
file. The jar file can typically be run on any platform that has JVM installed. cargo build
in Rust does the same, except the Rust compiler statically links (although there exist other linking options) all code into a single, platform-dependent, binary.
In Java, you can use a tool like GraalVM to create a native binary executable, similar to what Rust does with cargo build
.
See also:
Dependencies
In Java, the contents of the pom.xml
file define the build options and
project dependencies. In Rust, when using Cargo, a Cargo.toml
declares the
dependencies for a package.
A typical pom.xml
file would look like:
<project>
<groupId>com.example</groupId>
<artifactId>simplerestapi</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rest-api-demo</name>
<description>Simple REST API demo project</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
...
</dependencies>
<build>
....
</build>
</project>
The equivalent Cargo.toml
in Rust is defined as:
[package]
name = "rest-api-demo"
version = "0.1.0"
description = "Simple REST API demo project"
[dependencies]
tokio = "1.0.0"
Cargo follows a convention that src/main.rs
is the crate root of a binary
crate with the same name as the package. Likewise, Cargo knows that if the
package directory contains src/lib.rs
, the package contains a library crate
with the same name as the package.
Package registries
The most common package registry for Java is the Maven Central repository, whereas Rust packages are usually shared via crates.io.
Project Structure
In Java, a standard Maven project would have the following structure:
project directory/
.
+-- src/
| +-- main/
| +-- java/
| +-- Application.java
| +-- resources
| +-- application.properties
| +-- test/
| +-- java/
| +-- ApplicationTests.java
+-- target/
| +-- build output files
+-- mvnw
+-- mvnw.cmd
+-- pom.xml
- Both Java source code and the corresponding test files are contained in the
src/
directory. - The
pom.xml
file and the Maven wrappers are stored at the root of the project. - The
target
directory contains all the build output files.
Cargo uses the following conventions for the package layout to make it easy to dive into a new Cargo package:
project directory/
.
+-- Cargo.lock
+-- Cargo.toml
+-- src/
| +-- lib.rs
| +-- main.rs
+-- benches/
| +-- some-bench.rs
+-- examples/
| +-- some-example.rs
+-- tests/
+-- some-integration-test.rs
+-- target/
| +-- build output files
Cargo.toml
andCargo.lock
are stored in the root of the package.src/lib.rs
is the default library file, andsrc/main.rs
is the default executable file (see target auto-discovery).- Benchmarks go in the
benches
directory, integration tests go in thetests
directory (see testing, benchmarking). - Examples go in the
examples
directory. - There is no separate crate for unit tests, unit tests live in the same file as the code (see testing).
Managing large projects
For very large projects in Rust, Cargo offers workspaces to organize the project. A workspace can help manage multiple related packages that are developed in tandem. Some projects use virtual manifests, especially when there is no primary package.
Meta Programming
Metaprogramming can be seen as a way of writing code that writes/generates other code.
Java does not have facilities for "proper" metaprogramming. Instead, it offers capabilities for dynamic (runtime) introspection/reflection, mostly through the Reflection API.
Rust on the other hand has strong support for metaprogramming through a dedicated language feature: macros. There are two main types of Rust macros: declarative macros
and procedural macros
.
Declarative macros allow you to write control structures that take an expression, compare the resulting value of the expression to patterns, and then run the code associated with the matching pattern. Declarative macros are also sometimes referred to as macros by example.
An example of a declarative macro is the println!
macro that we've used quite a bit. It is used for printing some text to the standard output: println!("Some text")
. Here's the definition for the macro:
macro_rules! println {
() => {
$crate::print!("\n")
};
($($arg:tt)*) => {{
$crate::io::_print($crate::format_args_nl!($($arg)*));
}};
}
To learn more about writing declarative macros, refer to the Rust reference chapter macros by example or The Little Book of Rust Macros.
Procedural macros are different from declarative macros. Those accept some code as an input, operate on that code, and produce some code as an output.
Note: Rust does not support reflection.
Function-like macros
Function-like macros take the following form: function!(...)
The following code snippet defines a function-like macro named
print_something
, which is generating a print_it
method for printing the
"Something" string.
In the lib.rs
file:
extern crate proc_macro;
use proc_macro::TokenStream;
#[proc_macro]
pub fn print_something(_item: TokenStream) -> TokenStream {
"fn print_it() { println!(\"Something\") }".parse().unwrap()
}
In the main.rs
file:
use replace_crate_name_here::print_something;
print_something!();
fn main() {
print_it();
}
Derive macros
Derive macros can create new items given the token stream of a struct, enum, or
union. An example of a derive macro is the #[derive(Clone)]
, which is generates the needed code for making the input struct/enum/union implement the Clone
trait.
In order to understand how to define a custom derive macro, read the rust reference for derive macros.
Attribute macros
Attribute macros define new attributes which can be attached to Rust items. While working with asynchronous code, if making use of Tokio, the first step will be to decorate the new asynchronous main with an attribute macro like in the following example:
#[tokio::main]
async fn main() {
println!("Hello world");
}
In order to understand how to define a custom derive macro, read the rust reference for attribute macros.
Asynchronous Programming
Unlike Java, Rust supports the asynchronous programming model. There are different async runtimes for Rust, the most popular being Tokio. The other options are async-std and smol.
Here's a simple example of how to define an asynchronous function in Rust. The example relies on async-std
for the implementation of sleep
:
use std::time::Duration;
use async_std::task::sleep;
async fn format_delayed(message: &str) -> String {
sleep(Duration::from_secs(1)).await;
format!("Message: {}", message)
}
Note: The Rust
async
keyword transforms a block of code into a state machine that implements theFuture
trait. This allows for writing asynchronous code sequentially.
See also:
Next Steps
As stated in the introduction, this guide is not meant to be a replacement for proper learning of the Rust language. Rust has great documentation and books that you can explore to continue with your journey of mastering the language.
Here are a few recommendations for your next steps:
- Check out the Learn Rust page of the official Rust website for excellent resources.
- Rust for Rustaceans, a book by Jon Gjengset is ideal after becoming comfortable with the basics of the language.
- Programming Rust, a book by Jim Blandy, et al.
- Effective Rust by David Drysdale.