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:

FeatureJavaRustNote
ClassesYesNoSee note 1.
InterfacesYesNoSee note 2.
EnumsYesYesSee note 3.
GenericsYesYes
ExceptionsYesNo
Virtual threadsYesNo
Asynchronous ProgrammingNoYes
Garbage CollectorYesNoSee note 4.

Notes:

  1. Rust has no classes. It has structures (struct) instead.

  2. Rust has a concept similar to interfaces called Traits.

  3. Enums in Rust are more powerful. Rust enums are most similar to algebraic data types in functional languages, such as OCaml and Haskell.

  4. 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

The following table lists the primitive types in Rust and their equivalent in Java:

RustJavaJava Wrapper ClassNote
boolbooleanBoolean
charcharCharacterSee note 1.
i8byteByte
i16shortShort
i32intIntegerSee note 2.
i64longLong
i128
isizeSee note 3.
u8
u16
u32
u64
u128
usizeSee note 3.
f32floatFloat
f64doubleDouble
()voidVoidSee note 4.

Notes:

  1. char in Rust and Character in JVM have different definitions. In Rust, a char is 4 bytes wide that is a Unicode scalar value, but in the Java Platform, a Character is 2 bytes wide (16-bit fixed-width) and stores the character using the UTF-16 encoding. For more information, see the Rust char documentation.

  2. 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.

  3. Rust also provides the isize and usize 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.

  4. While a unit () (an empty tuple) in Rust is an expressible value, the closest cousin in Java would be void 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:

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:

RustJavaNote
&strStringsee Note 1.
Box<str>Stringsee Note 2.
StringString
String (mutable)StringBuildersee 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:

  1. In Rust, &str (pronounced: string slice) is an immutable string reference type.

  2. The Box<str> type in Rust is equivalent to the String type in JVM. The difference between the Box<str> and String types in Rust is that the former stores pointer and size while the latter stores pointer, size, and capacity, allowing String to grow in size. This is similar to the StringBuilder type in JVM once the Rust String 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 the Display trait.

See also:


1

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:

RustJavaJava InterfaceNote
ArrayArraysee Note 1.
VecArrayListList
HashMapHashMapMap
HashSetHashSetSet
Tuplesee Note 2.
LinkedListLinkedListListsee Note 3.

Notes:

  1. Java provides the Arrays utility class for manipulating arrays.

  2. Unlike Rust, Java does not have the Tuple type.

  3. 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 as for-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:

  1. Using while:
while (true) {
    // do something
}
  1. 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 structsJava 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:

  1. This is strictly not necessary. Added for the sake of comparison with the Rust version.

  2. 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).


1

Default methods in interfaces were introduced in Java 8.

2

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:

JavaRustNote
private(default)See note 1.
protectedN/ASee note 2.
package privatepub(crate)
publicpub
  1. There is no keyword to denote private visibility; it's the default in Rust.

  2. 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
}

1

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.

JavaRustNote
reducereduceSee note 1.
reducefoldSee note 1.
allMatchall
anyMatchany
concatchain
countcount
maxmax
minmin
mapmap
flatMapflat_map
skipskip
limittake
takeWhiletake_while
collectcollectSee note 2.
filterfilter
  1. Java's reduce method is overloaded. The one not accepting an identity value is equivalent to Rust's reduce, while the one accepting an identity value corresponds to Rust's fold.

  2. collect in Rust generally works for any collectible type, which is defined as a type that can initialize itself from an iterator (see FromIterator). collect needs a target type, which the compiler sometimes has trouble inferring so the turbofish (::<>) is often used in conjunction with it, as in collect::<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
  1. The equality operator == is used to compare for reference equality. In this case, variables a and b are referring/pointing to different objects in memory.

  2. 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 of PartialEq.

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(&timestamped2)); // prints: Equal
    println!("{:?}", timestamped1.cmp(&timestamped3)); // prints: Greater
    println!("{:?}", timestamped1.cmp(&timestamped4)); // 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
}

1

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 heap
  • Rc<T>/ Arc<T> : reference counting type that enables multiple value ownership
  • RefCell<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:

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:

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 the Clone 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 the drop method of the Drop trait and prints a message when an instance of a Point is dropped.

  • The point created in main is wrapped behind the smart pointer Rc and so the smart pointer owns the point and not p1.

  • p2 gets a clone of the smart pointer that effectively increments the reference count to 2. Unlike the earlier example, where p2 transferred its ownership of point to p2, both p1 and p2 own their own distinct clones of the smart pointer, so it is okay to continue to use p1 and p2.

  • The compiler will have determined that p1 and p2 go out of scope at the end of main and therefore injected calls to drop each. The Drop implementation of Rc will decrement the reference count and also drop what it owns if the reference count has reached zero. When that happens, the Drop implementation of Point 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.


1

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:

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:

JavaRust
Threadstd::thread::Thread
Thread.startstd::thread::spawn
Thread.joinstd::thread::JoinHandle
Thread.sleepstd::thread::sleep
synchronized-
ReentrantLockstd::sync::Mutex
ReentrantReadWriteLockstd::sync::RwLock
Semaphore-
Conditionstd::sync::Condvar
java.util.concurrent.atomic.*std::sync::atomic
volatile-
ThreadLocalstd::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 an Arc (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 of data (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:

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 and Cargo.lock are stored in the root of the package.
  • src/lib.rs is the default library file, and src/main.rs is the default executable file (see target auto-discovery).
  • Benchmarks go in the benches directory, integration tests go in the tests 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 the Future 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: