The Oxide Programming Language
Welcome to The Oxide Programming Language, an introductory book about Oxide.
Oxide is an alternative syntax for Rust that provides familiar Swift/Kotlin-inspired conventions while producing identical binary output to equivalent Rust code. It maintains all of Rust's safety guarantees, ownership model, and performance while making systems programming more accessible.
Who This Book Is For
This book is for developers transitioning from Swift, Kotlin, TypeScript, or C# who want to leverage Rust's performance and safety guarantees without navigating its unfamiliar syntax.
How to Use This Book
This book follows the same structure as The Rust Programming Language book. If you're already familiar with Rust, you can skip to the specific chapters that cover Oxide's syntax differences.
Installation
Let's get Oxide set up on your computer! We'll install the necessary tools to compile and run Oxide programs.
What is Oxide?
Oxide is an alternative syntax for Rust that compiles to identical binary output via a rustc fork. If you're coming from Swift, Kotlin, TypeScript, or C#, Oxide's syntax will feel familiar while giving you access to Rust's powerful safety guarantees and performance.
Key points about Oxide:
- Rust with familiar syntax: Oxide uses Swift/Kotlin-inspired conventions as an alternative to Rust's conventions
- 100% compatible with Rust: Both
.oxfiles (Oxide) and.rsfiles (Rust) can coexist in the same project - Same performance: Oxide compiles to the exact same machine code as Rust—zero runtime overhead
- All of Rust's power: You get ownership, borrowing, the type system, traits, generics, and everything else that makes Rust safe
Let's look at a quick example of what Oxide looks like:
// Oxide: familiar and readable
fn greet(name: str): String {
"Hello, \(name)!"
}
Compared to equivalent Rust:
#![allow(unused)] fn main() { // Rust: using Rust's conventions fn greet(name: &str) -> String { format!("Hello, {}", name) } }
Both compile to identical code. The difference is the syntax.
Prerequisites
Before installing Oxide, you'll need:
- Rust toolchain - The Rust compiler (
rustc), Cargo package manager, and Rust standard library - A text editor or IDE - Any editor works; VS Code with Rust Analyzer is recommended
- A terminal - Oxide development happens on the command line
Since Oxide compiles via a rustc fork, having the standard Rust toolchain installed is essential, even though you'll primarily use Oxide syntax.
Installation
Step 1: Install Rust
First, install Rust using rustup. Open your terminal and run:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
This downloads and installs rustup, which manages your Rust toolchain. Follow the on-screen prompts.
On Windows, download and run rustup-init.exe from https://rustup.rs.
After installation, verify Rust is installed:
rustc --version
cargo --version
You should see version numbers for both.
Step 2: Install Oxide (Future Reference)
Note: The Oxide compiler is currently in development. This section describes how you'll install it once it's released.
Once available, you'll install the Oxide compiler via Cargo:
cargo install oxidec
This installs the oxidec command, which is the Oxide compiler. It's a wrapper around the rustc fork that handles .ox files transparently.
Verify the installation:
oxidec --version
You should see the Oxide compiler version.
For Now: Setting Up Your First Project
While the compiler is under development, you can explore Oxide syntax and concepts by:
- Reading this book and the examples
- Experimenting with the ideas in Rust code
- Preparing your workflow for when Oxide releases
Setting Up an Oxide Project
Creating a new Oxide project is straightforward using Cargo with the --oxide flag:
cargo new --oxide hello_oxide
cd hello_oxide
This creates an Oxide project with src/main.ox containing:
fn main() {
println!("Hello, world!")
}
Development Status: Oxide v1.0 is currently in development. The Oxide compiler (a fork of rustc) and the
--oxideflag for Cargo are being implemented and will be available when the Oxide toolchain is released. For early testing before the release, you can manually create a project withcargo newand renamesrc/main.rstosrc/main.ox.
Here's what a basic project structure looks like:
hello_oxide/
├── Cargo.toml
└── src/
├── main.ox # Your Oxide code
└── lib.rs # Optional: Rust library code (can coexist)
When you run cargo build or cargo run, Oxide files (.ox) and Rust files (.rs) work together seamlessly. Both compile to identical machine code.
Building and Running
Build and run your Oxide project:
cargo build
cargo run
The Oxide compiler processes your .ox files and compiles them via the rustc fork.
Troubleshooting
"command not found: oxidec"
Ensure you've installed the Oxide compiler with cargo install oxidec and that ~/.cargo/bin is in your PATH.
"Can't find Rust toolchain"
The Oxide compiler requires the Rust toolchain. Verify Rust is installed with rustc --version. If not, follow Step 1 above.
"Unknown file extension: .ox"
Make sure your file has the .ox extension (lowercase). Oxide files must use this specific extension to be recognized.
IDE/Editor support
If your editor doesn't recognize .ox files:
- VS Code: Install the Oxide extension (coming soon)
- Other editors: Configure them to treat
.oxfiles as Rust for syntax highlighting until Oxide support is available
Moving Forward
You're now ready to start learning Oxide! The next chapter will guide you through writing your first program. Whether you're new to systems programming or transitioning from another language, Oxide provides syntax that may feel familiar while giving you access to Rust's excellent type system and safety guarantees.
If you get stuck, remember:
- The examples in this book are all valid Oxide code
- You can always reference the Rust equivalent to understand the underlying semantics
- Rust's extensive ecosystem documentation applies directly to Oxide code
- Both Oxide and Rust are valid choices—Oxide simply offers an alternative syntax
Happy coding!
Hello, World!
It's traditional to begin learning a new programming language by writing a little program that prints the text "Hello, world!" to the screen, so we'll do that here! You'll also write your first Oxide program.
Creating a Project Directory
First, create a directory where you'll store your Oxide code. It doesn't matter to Oxide where your code lives, but for the exercises and projects in this book, we suggest creating a projects directory in your home directory and keeping all your projects there.
Open a terminal and run the following commands to create a projects directory and a hello_world project within it.
On Linux, macOS, and PowerShell on Windows, enter this:
$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world
On Windows CMD, enter this:
> mkdir %USERPROFILE%\projects
> cd /d %USERPROFILE%\projects
> mkdir hello_world
> cd hello_world
Writing and Running the Program
Next, make a new source file and call it main.ox. Oxide files always end with the .ox extension. If you're using more than one word in your filename, the convention is to use an underscore to separate them. For example, you'd use hello_world.ox rather than helloworld.ox.
Now open the main.ox file you just created and enter the code in Listing 1-1:
Filename: main.ox
fn main() {
println!("Hello, world!")
}
Listing 1-1: A program that prints "Hello, world!"
Save the file and go back to your terminal window in the ~/projects/hello_world directory. On Linux or macOS, enter the following commands to compile and run the file:
$ oxidec main.ox
$ ./main
Hello, world!
On Windows, enter:
> oxidec main.ox
> .\main.exe
Hello, world!
Regardless of your operating system, the string Hello, world! should print to the terminal. If you didn't see this output, refer to the Troubleshooting section for ways to get help.
Anatomy of an Oxide Program
Let's review this "Hello, world!" program in detail. Here's the first piece:
fn main() {
}
These lines define a function named main. The main function is special: it's always the first code that runs in every executable Oxide program. We declare it using the keyword fn, and it takes no parameters and returns nothing. If there were parameters, they would go inside the parentheses (). Also notice that the function body is wrapped in curly brackets {}. Oxide requires curly brackets around all function bodies.
Next is this line:
println!("Hello, world!")
This line does all the work in this little program. It calls the println! macro with the string "Hello, world!" as an argument. The ! indicates that println! is a macro rather than a normal function. (We'll learn more about macros in detail in Chapter 19.) The macro prints the string to the screen.
Semicolons and Optional Syntax
You might notice this program doesn't have a semicolon at the end of the println! line. In Oxide, semicolons are optional in most contexts. They're not required at the end of function calls or statements. You can write it either way:
fn main() {
println!("Hello, world!")
}
Or with semicolons, if you prefer:
fn main() {
println!("Hello, world!");
}
Both are valid Oxide. The choice is stylistic—pick what feels most natural to you.
Comparison with Rust
If you're familiar with Rust, you might notice some differences. In Rust, you would write:
fn main() { println!("Hello, world!"); }
The key differences between Oxide and Rust in this example:
- File extension: Oxide uses
.oxinstead of.rs - Semicolons: Oxide makes them optional; Rust requires them
- Compiler: Oxide uses
oxidec(the Oxide compiler, which is a rustc fork); Rust usesrustc
Everything else—the function syntax, the println! macro, and the overall program structure—is identical to Rust. This is because Oxide is fundamentally Rust with a different surface syntax.
Compiling and Running Are Separate Steps
You've just seen how to run a new program, but let me explain the process more fully.
Before running an Oxide program, you must compile it using the Oxide compiler, even for simple programs. The command is oxidec followed by your source filename:
$ oxidec main.ox
If you're on Windows, use:
> oxidec main.ox
This command compiles your main.ox file and creates an executable called main (or main.exe on Windows). You can then run the executable:
On Linux or macOS:
$ ./main
Hello, world!
On Windows:
> .\main.exe
Hello, world!
Even for one-line programs, you need to explicitly compile before running. This follows Rust's ahead-of-time compilation approach, which differs from interpreted languages like Python or JavaScript where you can run code directly. The benefit is performance: compiled code runs much faster than interpreted code, and you catch errors at compile time rather than runtime.
Troubleshooting
The most common problems beginners encounter:
Command not found: oxidec
This usually means the Oxide compiler isn't installed or isn't in your system's PATH. Refer to the Installation chapter to properly install Oxide.
Compilation errors
If you see errors when running oxidec, double-check that:
- Your file is saved as
main.ox(or another.oxfilename) - You're running the command from the directory containing your source file
- Your code matches Listing 1-1 exactly, including the curly brackets and parentheses
Program output doesn't appear
- On macOS or Linux, make sure you're using
./main(with the dot-slash) to run the executable - On Windows, make sure you're using
.\main.exe(with the backslash) - If the window closes immediately on Windows, try running it from PowerShell or Command Prompt directly
Congratulations! You've officially written your first Oxide program. Next, we'll look at Oxide's package manager and build system, Cargo, which makes creating more complex programs much easier.
Hello, Cargo!
Cargo is Rust's excellent build system and package manager, and it makes Rust projects much easier to manage. You can use Cargo to create new projects, build them, test them, and distribute them. The good news is that Cargo works seamlessly with Oxide—.ox files are treated identically to .rs files. Oxide benefits directly from Cargo's powerful tooling and ecosystem.
What is Cargo?
Cargo handles several important tasks for you:
- Building your code with
cargo build - Running your code with
cargo run - Testing your code with
cargo test - Generating documentation with
cargo doc - Publishing libraries to crates.io
- Managing dependencies through
Cargo.toml
Without Cargo, you'd need to manually compile your code with rustc, manage compilation flags, handle dependencies by hand, and coordinate all these tasks yourself. With Cargo, everything is automated and standardized.
Creating an Oxide Project
To create a new Oxide project, use Cargo with the --oxide flag:
$ cargo new --oxide hello_oxide
Created binary (application) `hello_oxide` package
$ cd hello_oxide
This generates an Oxide project with src/main.ox containing:
fn main() {
println!("Hello, world!")
}
Development Status: Oxide v1.0 is currently in development. The
--oxideflag will be available in Cargo once the Oxide toolchain is released. For early testing before the release, you can manually create a project withcargo newand renamesrc/main.rstosrc/main.ox.
Let's see what Cargo generated:
$ cd hello_oxide
$ ls -la
drwxr-xr-x .git
drwxr-xr-x .gitignore
-rw-r--r-- Cargo.toml
drwxr-xr-x src
Let's look at the directory structure:
$ tree .
.
├── Cargo.toml
└── src
└── main.ox
Understanding Cargo.toml
The Cargo.toml file is the manifest for your project. It contains metadata about your package and its dependencies. Here's what was created for us:
[package]
name = "hello_oxide"
version = "0.1.0"
edition = "2021"
[dependencies]
The [package] section contains metadata:
- name: The name of your project
- version: The current version of your code
- edition: The Rust edition (Oxide projects use the same editions as Rust)
The [dependencies] section is where you'd list any external crates your project depends on. We don't have any dependencies yet.
Understanding the src Directory
Cargo expects your source files to be in the src directory. With the --oxide flag, Cargo creates main.ox with a simple "Hello, World!" program:
fn main() {
println!("Hello, world!")
}
This is valid Oxide code that compiles and runs just like Rust. Let's make it feel more Oxide-like by using string interpolation:
fn main() {
let language = "Oxide"
println!("Hello from \(language)!")
}
The differences from the original:
- Added a variable to demonstrate Oxide's string interpolation:
"\(language)" - Omitted semicolons (optional in Oxide)
- The
fn main()andprintln!()syntax work identically in both Oxide and Rust
Building and Running Your Oxide Project
Now let's build the project using Cargo:
$ cargo build
Compiling hello_oxide v0.1.0
Finished dev [unoptimized + debuginfo] target/debug/hello_oxide
Congratulations! You've successfully compiled your first Oxide program with Cargo. The executable has been created in target/debug/hello_oxide (or target/debug/hello_oxide.exe on Windows).
To run it, use cargo run:
$ cargo run
Compiling hello_oxide v0.1.0
Finished dev [unoptimized + debuginfo] target/debug/hello_oxide
Running `target/debug/hello_oxide`
Hello, Oxide!
The cargo run command compiles the code and then runs the resulting executable—all in one command. It's very convenient for projects you're actively working on.
A Note About Cargo.lock
When you first build your project, Cargo creates a Cargo.lock file. This file keeps track of the exact versions of dependencies you've built with. For binary projects (like this one), you should commit Cargo.lock to version control so everyone working on the project uses the same dependency versions. For libraries, it's typically not committed.
Cargo Check
If you want to verify that your code compiles without actually producing an executable, you can use cargo check:
$ cargo check
Checking hello_oxide v0.1.0
Finished dev [unoptimized + debuginfo] target/debug/hello_oxide
This command is much faster than cargo build because it stops after type-checking and doesn't generate code. It's perfect for getting quick feedback as you're writing code.
Using Cargo with Mixed Oxide and Rust
One powerful feature of Oxide is that you can freely mix .ox and .rs files in the same Cargo project. Cargo doesn't care which extension you use—both are compiled and linked together seamlessly.
For example, if you have existing Rust code you want to reuse, or if you're gradually migrating a project to Oxide, you can simply keep both file types in the same src/ directory:
src/
├── main.ox # Oxide code
├── utils.rs # Rust code
├── lib.ox # Oxide library code
└── integrations.rs # Rust integrations
Cargo will compile all of them together:
$ cargo build
Compiling hello_oxide v0.1.0
Finished dev [unoptimized + debuginfo] target/debug/hello_oxide
You can call Rust functions from Oxide and vice versa—they're compiled to the same intermediate representation and linked together. This makes it easy to adopt Oxide incrementally.
Thinking in Terms of Cargo
As you work with Rust and Oxide projects, here's a mental model for Cargo:
Cargo is to systems programming as npm is to Node.js or pip is to Python. It's the standard way projects are organized, built, and distributed. Rust's community designed Cargo exceptionally well, and Oxide benefits from this thoughtful tooling.
Every Rust and Oxide project you'll encounter follows the same structure:
- Source code in
src/ - Dependencies listed in
Cargo.toml - Binaries in
target/debug/ortarget/release/ - Tests alongside your source code
Learning Cargo now means you'll immediately understand the structure of any Rust or Oxide project you encounter. This standardization is one of Rust's great strengths.
Building for Release
So far, we've been building in development mode with cargo build. This mode is great for development because compilation is fast and includes debug information. However, it doesn't optimize your code, so the resulting executable is slower.
When you're ready to ship your code, or when you care about performance, use the --release flag:
$ cargo build --release
Compiling hello_oxide v0.1.0
Finished release [optimized] target/release/hello_oxide
The executable will be in target/release/hello_oxide. Release builds take longer to compile but run much faster. You can also use:
$ cargo run --release
Compiling hello_oxide v0.1.0
Finished release [optimized] target/release/hello_oxide
Running `target/release/hello_oxide`
Hello, Oxide!
For benchmarking or production deployment, always use --release.
Summary
Congratulations! You've learned the basics of working with Cargo and Oxide:
- Cargo new creates a new project structure
- Cargo build compiles your project
- Cargo run compiles and runs your project
- Cargo check quickly verifies your code compiles
.oxfiles work seamlessly with Cargo just like.rsfiles- Mixed projects are fully supported—use both
.oxand.rsfiles in the same crate - Release builds provide optimizations for production use
You now have everything you need to start building Oxide projects. In the next chapter, we'll explore the fundamental concepts of the language itself.
Programming a Guessing Game
Let's jump into Oxide by working through a hands-on project together! This chapter introduces several common Oxide concepts by showing you how to use them in a real program. You'll learn about var, input/output, string interpolation, match expressions, and more!
The project is a classic beginner programming problem: we'll implement a guessing game. Here's how it works: the program generates a random integer between 1 and 100, then prompts you to enter a guess. After you enter a guess, the program indicates whether the guess is too low or too high. If your guess is correct, the game prints a congratulatory message and exits.
Setting Up a New Project
Let's set up a new Oxide project using Cargo with the --oxide flag:
$ cargo new --oxide guessing_game
$ cd guessing_game
This creates a new Oxide project with src/main.ox ready to go.
Development Status: Oxide v1.0 is currently in development. The
--oxideflag will be available in Cargo once the Oxide toolchain is released. For early testing before the release, you can manually create a project withcargo newand renamesrc/main.rstosrc/main.ox.
Now let's look at what Cargo generated. Open Cargo.toml:
[package]
name = "guessing_game"
version = "0.1.0"
edition = "2021"
[dependencies]
And here's the initial src/main.ox:
fn main() {
println!("Hello, world!")
}
Let's test it with cargo run:
$ cargo run
Compiling guessing_game v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 1.23s
Running `target/debug/guessing_game`
Hello, world!
Great! The cargo run command compiles and runs the project in one step. Now let's make it into a guessing game.
Processing a Guess
The first part of the guessing game program will ask for user input, process that input, and check that the input is in the expected form. To start, we'll allow the player to input a guess.
Filename: src/main.ox
import std.io
fn main() {
println!("Guess the number!")
println!("Please input your guess.")
var guess = String.new()
io.stdin()
.readLine(&mut guess)
.expect("Failed to read line")
println!("You guessed: \(guess)")
}
This code contains a lot of information, so let's go through it line by line.
Getting User Input
To obtain user input and then print the result, we need the io library from the standard library:
import std.io
In Oxide, we use import with dot notation. This replaces Rust's use std::io; syntax. Note that :: does not exist in Oxide - dot notation is the only path separator.
The main function is the entry point:
fn main() {
Next, we use println! to print a prompt:
println!("Guess the number!")
println!("Please input your guess.")
Storing Values with Variables
Now we'll create a variable to store the user input:
var guess = String.new()
In Oxide, we use var to create a mutable variable. This is equivalent to Rust's let mut. The variable guess is bound to a new, empty String. In Oxide, String is the same type as in Rust—a growable, UTF-8 encoded text type.
Receiving User Input
Next, we call readLine on the standard input handle:
io.stdin()
.readLine(&mut guess)
The stdin function returns an instance of std.io.Stdin, a type representing a handle to the standard input. The .readLine(&mut guess) method reads user input into the string we pass to it.
We pass &mut guess as an argument. The & indicates this is a reference, which gives you a way to let multiple parts of your code access one piece of data without copying it into memory multiple times. References are immutable by default, so we need &mut to make it mutable.
Handling Potential Failure with Result
We're still working on this line:
.expect("Failed to read line")
The readLine method returns a Result value. Result is an enum with variants Ok and Err. The Ok variant indicates the operation was successful, and inside Ok is the successfully generated value. The Err variant means the operation failed, and contains information about how or why the operation failed.
The expect method will cause the program to crash and display the message you passed to it if the Result is an Err value. If you don't call expect, the program will compile with a warning.
Printing Values with String Interpolation
Finally, we print the guess:
println!("You guessed: \(guess)")
Oxide supports string interpolation using \(expression) syntax. This is one of Oxide's ergonomic features that makes string formatting more concise. It's equivalent to Rust's format! macro.
Let's test this code:
$ cargo run
Compiling guessing_game v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 2.34s
Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6
Great! The first part is working.
Generating a Secret Number
Next, we need to generate a secret number that the user will try to guess. The secret number should be different every time so the game is fun to play more than once. Let's use a random number between 1 and 100.
Rust's standard library doesn't include random number functionality, so we'll use the rand crate. Add it to Cargo.toml:
Filename: Cargo.toml
[dependencies]
rand = "0.8.5"
Now update src/main.ox:
Filename: src/main.ox
import std.io
import rand.Rng
fn main() {
println!("Guess the number!")
let secretNumber = rand.thread_rng().gen_range(1..=100)
println!("The secret number is: \(secretNumber)")
println!("Please input your guess.")
var guess = String.new()
io.stdin()
.readLine(&mut guess)
.expect("Failed to read line")
println!("You guessed: \(guess)")
}
We add import rand.Rng. The Rng trait defines methods that random number generators implement.
We call rand.thread_rng() to get a random number generator, then call gen_range with the range 1..=100 (inclusive on both ends).
Note that we use let (not var) for secretNumber because we won't be changing it.
Let's run it:
$ cargo run
Compiling rand v0.8.5
Compiling guessing_game v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 3.45s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 42
Please input your guess.
25
You guessed: 25
You should see a different random number, and it should change each time you run the program.
Comparing the Guess to the Secret Number
Now that we have user input and a random number, we can compare them:
Filename: src/main.ox
import std.io
import std.cmp.Ordering
import rand.Rng
fn main() {
println!("Guess the number!")
let secretNumber = rand.thread_rng().gen_range(1..=100)
println!("The secret number is: \(secretNumber)")
println!("Please input your guess.")
var guess = String.new()
io.stdin()
.readLine(&mut guess)
.expect("Failed to read line")
let guess: Int = guess.trim().parse().expect("Please type a number!")
println!("You guessed: \(guess)")
match guess.cmp(&secretNumber) {
Ordering.Less -> println!("Too small!"),
Ordering.Greater -> println!("Too big!"),
Ordering.Equal -> println!("You win!"),
}
}
We import Ordering, which is an enum with variants Less, Greater, and Equal.
We convert the guess string to a number:
let guess: Int = guess.trim().parse().expect("Please type a number!")
We create a new variable also named guess. Oxide, like Rust, allows us to shadow the previous value. The trim method removes whitespace, and parse converts the string to a number. We specify the type as Int (Oxide's alias for i32).
Then we use a match expression to compare the guess to the secret number:
match guess.cmp(&secretNumber) {
Ordering.Less -> println!("Too small!"),
Ordering.Greater -> println!("Too big!"),
Ordering.Equal -> println!("You win!"),
}
In Oxide, we use -> for match arms (Rust's => is invalid in Oxide), and dot notation for enum variants (Ordering.Less). Rust's double colon syntax (Ordering::Less) does not exist in Oxide - it will cause a syntax error.
Let's try it:
$ cargo run
Compiling guessing_game v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 1.23s
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
76
You guessed: 76
Too big!
Nice! But we can only make one guess. Let's fix that with a loop.
Allowing Multiple Guesses with Looping
The loop keyword creates an infinite loop:
Filename: src/main.ox
import std.io
import std.cmp.Ordering
import rand.Rng
fn main() {
println!("Guess the number!")
let secretNumber = rand.thread_rng().gen_range(1..=100)
loop {
println!("Please input your guess.")
var guess = String.new()
io.stdin()
.readLine(&mut guess)
.expect("Failed to read line")
let guess: Int = guess.trim().parse().expect("Please type a number!")
println!("You guessed: \(guess)")
match guess.cmp(&secretNumber) {
Ordering.Less -> println!("Too small!"),
Ordering.Greater -> println!("Too big!"),
Ordering.Equal -> {
println!("You win!")
break
},
}
}
}
We've moved everything after the secret number generation into a loop. When the guess equals the secret number, we print "You win!" and break to exit the loop.
$ cargo run
Compiling guessing_game v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 1.45s
Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
50
You guessed: 50
Too small!
Please input your guess.
75
You guessed: 75
Too big!
Please input your guess.
62
You guessed: 62
You win!
Excellent! Now it loops until you guess correctly.
Handling Invalid Input
Let's make the game more robust by handling invalid input gracefully:
Filename: src/main.ox
import std.io
import std.cmp.Ordering
import rand.Rng
fn main() {
println!("Guess the number!")
let secretNumber = rand.thread_rng().gen_range(1..=100)
loop {
println!("Please input your guess.")
var guess = String.new()
io.stdin()
.readLine(&mut guess)
.expect("Failed to read line")
let guess: Int = match guess.trim().parse() {
Ok(num) -> num,
Err(_) -> {
println!("Please type a number!")
continue
},
}
println!("You guessed: \(guess)")
match guess.cmp(&secretNumber) {
Ordering.Less -> println!("Too small!"),
Ordering.Greater -> println!("Too big!"),
Ordering.Equal -> {
println!("You win!")
break
},
}
}
}
Instead of crashing with expect, we now use a match expression to handle the Result from parse. If parse returns Ok, we extract the number. If it returns Err, we print a message and continue to the next iteration of the loop.
Let's test it:
$ cargo run
Compiling guessing_game v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 1.23s
Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
abc
Please type a number!
Please input your guess.
50
You guessed: 50
Too small!
Please input your guess.
62
You guessed: 62
You win!
Perfect! Now let's remove the debug line that prints the secret number:
Filename: src/main.ox
import std.io
import std.cmp.Ordering
import rand.Rng
fn main() {
println!("Guess the number!")
let secretNumber = rand.thread_rng().gen_range(1..=100)
loop {
println!("Please input your guess.")
var guess = String.new()
io.stdin()
.readLine(&mut guess)
.expect("Failed to read line")
let guess: Int = match guess.trim().parse() {
Ok(num) -> num,
Err(_) -> {
println!("Please type a number!")
continue
},
}
println!("You guessed: \(guess)")
match guess.cmp(&secretNumber) {
Ordering.Less -> println!("Too small!"),
Ordering.Greater -> println!("Too big!"),
Ordering.Equal -> {
println!("You win!")
break
},
}
}
}
Summary
This project was a hands-on way to introduce you to many new Oxide concepts: var, match, functions, external crates, and more. In the next chapters, you'll learn about these concepts in more detail.
This project demonstrated Oxide's practical syntax while building on Rust's excellent type system and error handling. The guessing game uses the same robust foundation as any Rust program—ownership, borrowing, and the type system—but with syntax that may feel more familiar if you're coming from languages like Swift, Kotlin, or TypeScript.
In Chapter 3, you'll learn about concepts that most programming languages have, such as variables, data types, and functions, and see how they work in Oxide.
Variables and Mutability
Variables are fundamental to any programming language. In Oxide, every variable binding follows clear rules about mutability that help prevent bugs and make your code easier to reason about. If you're familiar with Rust, you'll find Oxide's approach very similar, but with syntax inspired by languages like Swift and Kotlin.
Immutable Bindings with let
When you declare a variable with let, it creates an immutable binding. This means once you assign a value, you cannot change it:
fn main() {
let x = 5
println!("The value of x is: \(x)")
}
If you try to reassign an immutable variable, the compiler will stop you:
fn main() {
let x = 5
println!("The value of x is: \(x)")
x = 6 // Error: cannot assign twice to immutable variable
}
This might seem restrictive at first, but immutability is a powerful feature. When a value cannot change, you can trust that it stays the same throughout your program. This eliminates entire categories of bugs and makes code easier to understand, especially in larger projects.
Rust comparison: The let keyword works identically to Rust. The only visible difference is that semicolons are optional in Oxide.
#![allow(unused)] fn main() { // Rust let x = 5; println!("The value of x is: {}", x); }
Mutable Bindings with var
Of course, sometimes you need values that can change. Oxide uses the var keyword for mutable bindings:
fn main() {
var x = 5
println!("The value of x is: \(x)")
x = 6
println!("The value of x is: \(x)")
}
This outputs:
The value of x is: 5
The value of x is: 6
With var, you can modify the value as many times as needed. Use var when you know a value will change, like a counter in a loop or an accumulator.
Rust comparison: Oxide's var is equivalent to Rust's let mut. The choice of var is intentional, as it's a familiar keyword from Swift, Kotlin, JavaScript, and many other languages.
#![allow(unused)] fn main() { // Rust equivalent let mut x = 5; x = 6; }
Constants
Constants are values that are bound to a name and cannot change throughout the entire program. Unlike let bindings, constants:
- Must always have a type annotation
- Must be set to a constant expression (evaluated at compile time)
- Can be declared in any scope, including the global scope
- Use the
constkeyword andSCREAMING_SNAKE_CASEby convention
const THREE_HOURS_IN_SECONDS: Int = 60 * 60 * 3
fn main() {
println!("Three hours is \(THREE_HOURS_IN_SECONDS) seconds")
}
Constants are useful for values that many parts of your code need to know about, like the maximum number of players in a game or the speed of light. Naming hardcoded values as constants helps future readers understand the significance of the value.
Rust comparison: Constants work identically to Rust. The only difference is using Oxide's type aliases (Int instead of i32).
#![allow(unused)] fn main() { // Rust const THREE_HOURS_IN_SECONDS: i32 = 60 * 60 * 3; }
Shadowing
You can declare a new variable with the same name as a previous variable. This is called shadowing, and the new variable shadows the previous one:
fn main() {
let x = 5
let x = x + 1
{
let x = x * 2
println!("The value of x in the inner scope is: \(x)")
}
println!("The value of x is: \(x)")
}
This outputs:
The value of x in the inner scope is: 12
The value of x is: 6
Shadowing is different from marking a variable as mutable with var. If we try to reassign without let, we get a compile-time error. By using let, we can perform transformations on a value but have the variable be immutable after those transformations.
Another advantage of shadowing is that you can change the type of a value while reusing the same name:
fn main() {
let spaces = " "
let spaces = spaces.len()
println!("Number of spaces: \(spaces)")
}
The first spaces is a &str, and the second spaces is a UIntSize (the return type of .len()). This is allowed because we're creating a new variable with let.
If we tried to use var and reassign, we'd get a type mismatch error:
fn main() {
var spaces = " "
spaces = spaces.len() // Error: mismatched types
}
Type Annotations
Oxide can usually infer the type of a variable from its value, but you can also be explicit:
fn main() {
let count: Int = 42
let name: String = "Alice".toString()
let active: Bool = true
var score: Float = 0.0
score = 99.5
}
Type annotations use a colon followed by the type, consistent with languages like TypeScript, Kotlin, and Swift. Oxide provides intuitive type aliases for primitives:
| Oxide Type | Rust Equivalent |
|---|---|
Int | i32 |
Float | f64 |
Bool | bool |
UIntSize | usize |
We'll explore all the available types in the next section.
Rust comparison: The annotation syntax is the same as Rust, just with Oxide's type names.
#![allow(unused)] fn main() { // Rust let count: i32 = 42; let active: bool = true; }
When to Use let vs var
As a general guideline:
- Default to
let: Start with immutable bindings. This makes your code safer and easier to reason about. - Use
varwhen needed: If you find you need to modify a value, change it tovar.
The compiler will tell you if you've marked something as immutable but try to change it. Following this approach helps you take advantage of the safety that Oxide provides while still having the flexibility to use mutable state when appropriate.
Summary
- Use
letfor immutable bindings that cannot change after initialization - Use
varfor mutable bindings that you need to modify - Use
constfor compile-time constants with global scope - Shadowing allows reusing names while keeping immutability benefits
- Type annotations use the format
name: Type
Now that you understand how variables work, let's look at the different data types available in Oxide.
Data Types
Every value in Oxide has a data type, which tells the compiler what kind of data is being specified so it knows how to work with that data. Oxide is statically typed, meaning the compiler must know the types of all variables at compile time. The compiler can usually infer types from values and how we use them, but when many types are possible, we must add a type annotation.
Scalar Types
A scalar type represents a single value. Oxide has four primary scalar types: integers, floating-point numbers, Booleans, and characters. Oxide provides intuitive type aliases that will feel familiar if you're coming from Swift, Kotlin, or TypeScript.
Integer Types
An integer is a number without a fractional component. Oxide provides signed and unsigned integers of various sizes:
| Oxide Type | Rust Equivalent | Size | Range |
|---|---|---|---|
Int8 | i8 | 8-bit | -128 to 127 |
Int16 | i16 | 16-bit | -32,768 to 32,767 |
Int32 | i32 | 32-bit | -2.1B to 2.1B |
Int64 | i64 | 64-bit | Very large |
Int | i32 | 32-bit | Default signed integer |
IntSize | isize | arch | Pointer-sized signed |
UInt8 | u8 | 8-bit | 0 to 255 |
UInt16 | u16 | 16-bit | 0 to 65,535 |
UInt32 | u32 | 32-bit | 0 to 4.3B |
UInt64 | u64 | 64-bit | Very large |
UInt | u32 | 32-bit | Default unsigned integer |
UIntSize | usize | arch | Pointer-sized unsigned |
The Int type (which maps to Rust's i32) is the default choice for integers and is generally the fastest, even on 64-bit systems. Use UIntSize when indexing collections, as it matches the size of memory addresses on your system.
fn main() {
let age: Int = 30
let temperature: Int = -15
let count: UIntSize = 1000
// Type inference works too
let inferred = 42 // Defaults to Int
}
Integer Literals
You can write integer literals in various forms:
| Literal | Example |
|---|---|
| Decimal | 98_222 |
| Hex | 0xff |
| Octal | 0o77 |
| Binary | 0b1111_0000 |
Note that underscores can be inserted for readability: 1_000_000 is the same as 1000000.
Floating-Point Types
Oxide has two floating-point types for numbers with decimal points:
| Oxide Type | Rust Equivalent | Size | Precision |
|---|---|---|---|
Float32 | f32 | 32-bit | ~6-7 digits |
Float64 | f64 | 64-bit | ~15-16 digits |
Float | f64 | 64-bit | Default floating-point |
The Float type (which maps to Rust's f64) is the default because modern CPUs handle double-precision floats nearly as fast as single-precision, and it provides more accuracy.
fn main() {
let pi: Float = 3.14159
let temperature = 98.6 // Inferred as Float
let precise: Float64 = 2.718281828459045
// Scientific notation
let avogadro: Float = 6.022e23
}
Numeric Operations
Oxide supports the standard mathematical operations: addition, subtraction, multiplication, division, and remainder:
fn main() {
// Addition
let sum = 5 + 10
// Subtraction
let difference = 95.5 - 4.3
// Multiplication
let product = 4 * 30
// Division
let quotient = 56.7 / 32.2
let truncated = 5 / 3 // Results in 1 (integer division)
// Remainder
let remainder = 43 % 5
}
The Boolean Type
Oxide's Boolean type has two possible values: true and false. Booleans are one byte in size and are specified using the Bool type:
fn main() {
let isActive: Bool = true
let isComplete = false // Inferred as Bool
// Booleans are often the result of comparisons
let isGreater = 5 > 3 // true
}
Rust comparison: Oxide uses Bool instead of bool, following the convention of capitalizing type names.
The Character Type
The char type represents a single Unicode scalar value. Character literals use single quotes:
fn main() {
let letter = 'a'
let emoji = '😀'
let heart = '❤'
}
The char type is four bytes and represents a Unicode Scalar Value, which means it can represent much more than just ASCII.
Compound Types
Compound types can group multiple values into one type. Oxide has two primitive compound types: tuples and arrays.
Tuples
A tuple groups together values of different types into one compound type. Tuples have a fixed length; once declared, they cannot grow or shrink.
fn main() {
let tup: (Int, Float, Bool) = (500, 6.4, true)
// Destructuring
let (x, y, z) = tup
println!("The value of y is: \(y)")
// Access by index
let five_hundred = tup.0
let six_point_four = tup.1
let is_true = tup.2
}
The tuple without any values, (), is called the unit type and represents an empty value or empty return type.
Arrays
Arrays contain multiple values of the same type with a fixed length. Use square brackets for array literals:
fn main() {
let numbers: [Int; 5] = [1, 2, 3, 4, 5]
let months: [&str; 12] = [
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
]
// Initialize with same value
let zeros: [Int; 5] = [0; 5] // [0, 0, 0, 0, 0]
// Accessing elements
let first = numbers[0]
let second = numbers[1]
}
Arrays are useful when you want data on the stack rather than the heap, or when you need a fixed number of elements. For a collection that can grow or shrink, use Vec<T> instead.
The String Type
Oxide has two string types:
str: A string slice, usually seen as&str. This is an immutable reference to string data.String: A growable, heap-allocated string.
fn main() {
// String literal (type is &str)
let greeting = "Hello, world!"
// Create an owned String
let name: String = "Alice".toString()
// String with interpolation
let message = "Hello, \(name)!"
println!("\(message)")
}
String interpolation with \(expression) is a key Oxide feature. Any expression inside \() is evaluated and converted to a string:
fn main() {
let count = 42
let price = 19.99
println!("Count: \(count), Price: $\(price)")
println!("Total: $\(count as Float * price)")
}
Rust comparison: Rust uses format!("{}", x) for string formatting. Oxide's \(x) syntax is inspired by Swift and is more concise.
#![allow(unused)] fn main() { // Rust println!("Count: {}, Price: ${}", count, price); }
Nullable Types
Oxide has first-class support for nullable (optional) types using the ? suffix. A T? type can hold either a value of type T or null:
fn main() {
let maybeNumber: Int? = 42
let nothing: String? = null
// Check if value exists
if let number = maybeNumber {
println!("Got number: \(number)")
}
// Provide a default with ??
let value = maybeNumber ?? 0
// Force unwrap with !! (use carefully!)
let forced = maybeNumber!!
}
The T? syntax is equivalent to Rust's Option<T>, and null is equivalent to None:
| Oxide | Rust |
|---|---|
Int? | Option<i32> |
String? | Option<String> |
null | None |
Some(x) | Some(x) |
x ?? y | x.unwrap_or(y) |
x!! | x.unwrap() |
fn findUser(id: Int): User? {
if id == 1 {
Some(User { name: "Alice".toString() })
} else {
null
}
}
fn main() {
let user = findUser(1) ?? User { name: "Guest".toString() }
println!("Hello, \(user.name)")
}
Collection Types
Oxide uses Rust's standard collection types directly:
Vectors
Vec<T> is a growable array type:
fn main() {
// Create a vector with the vec! macro
var numbers: Vec<Int> = vec![1, 2, 3]
// Add elements
numbers.push(4)
numbers.push(5)
// Access elements
let first = numbers[0]
let maybe_tenth: Int? = numbers.get(10).copied()
// Iterate
for num in numbers.iter() {
println!("\(num)")
}
}
HashMaps
HashMap<K, V> stores key-value pairs:
import std.collections.HashMap
fn main() {
var scores: HashMap<String, Int> = HashMap.new()
scores.insert("Blue".toString(), 10)
scores.insert("Red".toString(), 50)
let blue_score = scores.get(&"Blue".toString())
for (team, score) in scores.iter() {
println!("\(team): \(score)")
}
}
Note: Oxide v1.0 uses Rust's collection names directly (Vec, HashMap) rather than providing aliases like Array or Dict. This helps you learn the actual Rust types you'll encounter in the ecosystem.
Type Inference
Oxide has strong type inference. The compiler can usually figure out types from context:
fn main() {
let x = 5 // Int
let y = 3.14 // Float
let z = true // Bool
let s = "hello" // &str
var items = vec![] // Vec<???> - needs annotation or usage
items.push(1) // Now compiler knows it's Vec<Int>
}
When the compiler cannot infer the type, you need to provide an annotation:
fn main() {
// Compiler needs help here
let guess: Int = "42".parse().unwrap()
// Or specify the type in the turbofish
let guess = "42".parse<Int>().unwrap()
}
Summary
Oxide provides intuitive type names that feel familiar to developers from many language backgrounds while mapping directly to Rust's type system:
- Integers:
Int,Int64,UInt,UIntSize, etc. - Floats:
Float,Float32,Float64 - Boolean:
Bool - Character:
char - Tuples:
(T, U, V) - Arrays:
[T; N] - Strings:
&str,Stringwith\(expr)interpolation - Nullable:
T?withnull,??, and!!operators - Collections:
Vec<T>,HashMap<K, V>
Since Oxide types ARE Rust types (just with different names), you get full compatibility with the entire Rust ecosystem.
Functions
Functions are pervasive in Oxide code. You've already seen the main function, which is the entry point of many programs. The fn keyword allows you to declare new functions.
Oxide uses camelCase for function and variable names, in contrast to Rust's snake_case. This follows the conventions of Swift, Kotlin, and TypeScript. When you call Rust code from Oxide, name conversion happens automatically.
Defining Functions
Functions are defined with fn, followed by a name, parameters in parentheses, and a body in curly braces:
fn main() {
println!("Hello, world!")
anotherFunction()
}
fn anotherFunction() {
println!("Another function.")
}
Functions can be defined before or after main; Oxide doesn't care where you define them, as long as they're in scope.
Parameters
Functions can have parameters, which are special variables that are part of the function's signature. When a function has parameters, you provide concrete values (called arguments) when you call it.
fn main() {
greet("Alice")
printSum(5, 3)
}
fn greet(name: &str) {
println!("Hello, \(name)!")
}
fn printSum(a: Int, b: Int) {
println!("\(a) + \(b) = \(a + b)")
}
Parameters must have type annotations. This is a deliberate design decision; requiring types in function signatures means the compiler rarely needs type annotations elsewhere.
Rust comparison: The syntax is identical, except Oxide uses camelCase for function names and its own type aliases.
#![allow(unused)] fn main() { // Rust fn print_sum(a: i32, b: i32) { println!("{} + {} = {}", a, b, a + b); } }
Return Values
Functions can return values. Declare the return type after a colon (:) following the parameter list:
fn five(): Int {
5
}
fn add(a: Int, b: Int): Int {
a + b
}
fn main() {
let x = five()
let sum = add(10, 20)
println!("x = \(x), sum = \(sum)")
}
The return value is the final expression in the function body. You can also return early using the return keyword:
fn absoluteValue(x: Int): Int {
if x < 0 {
return -x
}
x
}
Rust comparison: Oxide uses : for return types instead of Rust's ->. This aligns with TypeScript, Kotlin, and Swift conventions.
#![allow(unused)] fn main() { // Rust fn add(a: i32, b: i32) -> i32 { a + b } }
Statements and Expressions
Function bodies are made up of a series of statements optionally ending in an expression. Understanding the difference is important:
- Statements perform actions but don't return a value
- Expressions evaluate to a resulting value
fn main() {
// This is a statement (variable declaration)
let y = 6
// This block is an expression that evaluates to 4
let x = {
let temp = 3
temp + 1 // No semicolon - this is the block's value
}
println!("x = \(x)") // Prints: x = 4
}
Note that temp + 1 has no semicolon. Adding a semicolon would turn it into a statement, and the block would return () (the unit type) instead.
Visibility
By default, functions are private to their module. Use public to make them accessible from other modules:
public fn createUser(name: &str): User {
User { name: name.toString() }
}
fn helperFunction() {
// This is only accessible within this module
}
Rust comparison: Oxide uses public instead of pub. The word is spelled out for clarity.
#![allow(unused)] fn main() { // Rust pub fn create_user(name: &str) -> User { User { name: name.to_string() } } }
Generic Functions
Functions can be generic over types:
import std.fmt.Display
fn identity<T>(value: T): T {
value
}
fn printPair<T, U>(first: T, second: U)
where
T: Display,
U: Display,
{
println!("(\(first), \(second))")
}
fn main() {
let x = identity(42)
let s = identity("hello")
printPair(1, "one")
}
Generic constraints can be specified inline or with a where clause, just like in Rust.
Functions That Return Nothing
Functions that don't return a value implicitly return (), the unit type. You can omit the return type:
fn greet(name: &str) {
println!("Hello, \(name)!")
}
// This is equivalent:
fn greetExplicit(name: &str): () {
println!("Hello, \(name)!")
}
Functions That Never Return
Some functions never return, like those that always panic or loop forever. Use the Never type for these:
fn diverges(): Never {
panic!("This function never returns!")
}
fn infiniteLoop(): Never {
loop {
// Do something forever
}
}
Closures
Closures are anonymous functions you can store in variables or pass as arguments. Oxide uses a Swift-inspired syntax with curly braces:
fn main() {
// No parameters
let sayHello = { println!("Hello!") }
// One parameter
let double = { x -> x * 2 }
// Multiple parameters
let add = { x, y -> x + y }
// With type annotations
let parse = { s: &str -> s.parse<Int>().unwrap() }
sayHello()
println!("Double 5: \(double(5))")
println!("3 + 4: \(add(3, 4))")
}
Rust comparison: Oxide uses { params -> body } instead of Rust's |params| body.
#![allow(unused)] fn main() { // Rust let double = |x| x * 2; let add = |x, y| x + y; }
Implicit it Parameter
In trailing closures (closures passed as the last argument to a function), you can use the implicit it parameter for single-argument closures:
fn main() {
let numbers = vec![1, 2, 3, 4, 5]
// Using implicit `it`
let doubled = numbers.iter().map { it * 2 }.collect<Vec<Int>>()
// Equivalent with explicit parameter
let doubled = numbers.iter().map { x -> x * 2 }.collect<Vec<Int>>()
// Filter with `it`
let evens = numbers.iter().filter { it % 2 == 0 }
// More complex usage
let users = vec![user1, user2, user3]
let activeNames = users
.iter()
.filter { it.isActive }
.map { it.name.clone() }
.collect<Vec<String>>()
}
Important: The implicit it is only available in trailing closure position. You cannot use it in variable bindings:
// NOT allowed - it only works in trailing closures
let f = { it * 2 } // Error!
// Use explicit parameter instead
let f = { x -> x * 2 } // OK
Trailing Closure Syntax
When the last argument to a function is a closure, you can write it outside the parentheses:
fn main() {
let numbers = vec![1, 2, 3, 4, 5]
// Trailing closure syntax
numbers.forEach { println!("\(it)") }
// Equivalent to:
numbers.forEach({ println!("\(it)") })
// With other arguments
numbers.iter().fold(0, { acc, x -> acc + x })
}
Multi-Statement Closures
Closures can contain multiple statements:
fn main() {
let process = { item ->
let validated = validate(item)
let transformed = transform(validated)
transformed
}
let result = process(myItem)
}
Async Functions
Async functions allow non-blocking I/O operations. Oxide uses prefix await instead of Rust's postfix .await:
async fn fetchData(url: &str): Result<String, Error> {
let response = await client.get(url).send()?
let body = await response.text()?
Ok(body)
}
async fn main(): Result<(), Error> {
let data = await fetchData("https://example.com")?
println!("Got: \(data)")
Ok(())
}
Rust comparison: Oxide uses await expr (prefix) instead of Rust's expr.await (postfix).
#![allow(unused)] fn main() { // Rust async fn fetch_data(url: &str) -> Result<String, Error> { let response = client.get(url).send().await?; let body = response.text().await?; Ok(body) } }
The prefix await reads naturally left-to-right and matches the convention in Swift, Kotlin, JavaScript, and Python.
Function Pointers
You can store functions in variables and pass them around:
fn add(a: Int, b: Int): Int {
a + b
}
fn multiply(a: Int, b: Int): Int {
a * b
}
fn applyOperation(a: Int, b: Int, op: (Int, Int) -> Int): Int {
op(a, b)
}
fn main() {
let result1 = applyOperation(5, 3, add)
let result2 = applyOperation(5, 3, multiply)
println!("5 + 3 = \(result1)")
println!("5 * 3 = \(result2)")
}
Rust comparison: Function pointer types use : instead of -> for the return type.
#![allow(unused)] fn main() { // Rust fn apply_operation(a: i32, b: i32, op: fn(i32, i32) -> i32) -> i32 { op(a, b) } }
Summary
- Functions are declared with
fnand use camelCase names - Parameters require type annotations
- Return types follow
:(not->) - Use
public(notpub) for public visibility - Closures use
{ params -> body }syntax - The implicit
itparameter works in trailing closures - Async functions use prefix
await
Functions in Oxide are designed to be familiar to developers from modern language backgrounds while maintaining full compatibility with Rust's function system.
Comments
Comments are essential for documenting your code. Good comments explain why code exists, not just what it does. Oxide's comment syntax is identical to Rust's, which will be familiar if you've used C, C++, Java, or JavaScript.
Line Comments
The most common comment form is the line comment, which starts with // and continues to the end of the line:
fn main() {
// This is a line comment
let x = 5 // This comment follows code
// Comments can span
// multiple lines
// like this
let y = 10
}
Use line comments liberally to explain non-obvious logic:
fn calculateDiscount(price: Float, memberYears: Int): Float {
// Base discount for all members
var discount = 0.05
// Long-term members get additional rewards
// The formula was approved by marketing in Q3 2024
if memberYears >= 5 {
discount += 0.02 * (memberYears - 4).min(5) as Float
}
price * discount
}
Block Comments
For longer explanations, use block comments that start with /* and end with */:
fn main() {
/* This is a block comment.
It can span multiple lines
and is useful for longer explanations. */
let x = 5
/*
* Some developers prefer to format
* block comments with asterisks
* on each line for readability.
*/
}
Block comments can also be nested, which is useful when commenting out code that already contains comments:
fn main() {
/*
This outer comment contains:
/* An inner comment */
And continues after it.
*/
println!("Hello!")
}
In practice, line comments (//) are more commonly used than block comments.
Documentation Comments
Oxide supports special documentation comments that can be processed by documentation tools. These come in two forms.
Outer Documentation Comments (///)
Use /// to document the item that follows (functions, structs, enums, etc.):
/// Calculates the factorial of a non-negative integer.
///
/// # Arguments
///
/// * `n` - The number to calculate factorial for
///
/// # Returns
///
/// The factorial of `n`, or `null` if `n` is negative
///
/// # Examples
///
/// ```oxide
/// let result = factorial(5)
/// assert_eq!(result, Some(120))
/// ```
public fn factorial(n: Int): Int? {
if n < 0 {
return null
}
if n <= 1 {
return Some(1)
}
Some(n * factorial(n - 1)??)
}
Documentation comments support Markdown formatting, so you can include:
- Headers with
# - Code blocks with triple backticks
- Lists with
*or- - Bold with
**text** - Links with
[text](url)
Inner Documentation Comments (//!)
Use //! at the beginning of a file or module to document the module itself:
//! # String Utilities
//!
//! This module provides helper functions for string manipulation.
//!
//! ## Features
//!
//! - Case conversion
//! - Trimming and padding
//! - Search and replace
//!
//! ## Example
//!
//! ```oxide
//! import mylib.strings
//!
//! let result = strings.toTitleCase("hello world")
//! assert_eq!(result, "Hello World")
//! ```
public fn toTitleCase(s: &str): String {
// Implementation here
s.toString()
}
public fn capitalize(s: &str): String {
// Implementation here
s.toString()
}
Inner documentation comments are typically placed at the very top of a file, before any code.
Common Documentation Sections
By convention, documentation for public APIs follows a standard structure:
/// Brief one-line description of the function.
///
/// More detailed explanation if needed. This can span multiple
/// paragraphs and include any relevant background information.
///
/// # Arguments
///
/// * `param1` - Description of the first parameter
/// * `param2` - Description of the second parameter
///
/// # Returns
///
/// Description of what the function returns.
///
/// # Errors
///
/// Description of when this function returns an error.
///
/// # Panics
///
/// Description of when this function might panic.
///
/// # Safety
///
/// For unsafe functions, describe safety requirements.
///
/// # Examples
///
/// ```oxide
/// let result = myFunction(arg1, arg2)
/// ```
public fn myFunction(param1: Int, param2: &str): Result<String, Error> {
// Implementation
}
Not all sections are needed for every function. Use the sections that are relevant:
# Arguments- When parameters aren't self-explanatory# Returns- For non-obvious return values# Errors- For functions returningResult# Panics- When the function can panic# Safety- Required forunsafefunctions# Examples- Highly recommended for public APIs
Documenting Structs and Enums
Document each field or variant:
/// Represents a user in the system.
///
/// Users are the primary actors in our application and
/// can perform various actions based on their role.
#[derive(Debug, Clone)]
public struct User {
/// Unique identifier for the user.
id: Int,
/// Display name shown in the UI.
name: String,
/// Email address for notifications.
/// Must be verified before the user can post.
email: String,
/// Whether the user has admin privileges.
isAdmin: Bool,
}
/// Possible states for an order.
public enum OrderStatus {
/// Order has been placed but not yet processed.
Pending,
/// Order is being prepared for shipment.
Processing,
/// Order has been shipped to the customer.
/// Contains the tracking number.
Shipped { trackingNumber: String },
/// Order has been delivered successfully.
Delivered,
/// Order was cancelled.
/// Contains the reason for cancellation.
Cancelled { reason: String },
}
Best Practices
Write Comments for Your Future Self
Code that seems obvious today might be confusing in six months:
// BAD: States what the code does (obvious from reading it)
// Increment counter by 1
counter += 1
// GOOD: Explains why
// We count from 1 because the API expects 1-indexed results
counter += 1
Keep Comments Up to Date
Outdated comments are worse than no comments. When you change code, update the corresponding comments:
// BAD: Comment doesn't match code
// Returns the user's full name
fn getUsername(user: &User): String {
user.email.clone() // Actually returns email!
}
// GOOD: Comment matches code
// Returns the user's email as their display identifier
fn getUsername(user: &User): String {
user.email.clone()
}
Use Comments to Explain "Why", Not "What"
// BAD: Describes what the code does
// Loop through users and filter by active status
let activeUsers = users.iter().filter { it.isActive }
// GOOD: Explains the business reason
// Only active users should receive the weekly newsletter
let activeUsers = users.iter().filter { it.isActive }
Document Public APIs Thoroughly
Internal code can have lighter documentation, but public APIs deserve comprehensive docs:
/// Parses a date string in ISO 8601 format.
///
/// Accepts dates in the format `YYYY-MM-DD`. The time component
/// is optional and defaults to midnight UTC if not provided.
///
/// # Arguments
///
/// * `input` - A string slice containing the date to parse
///
/// # Returns
///
/// A `DateTime` if parsing succeeds, or `null` if the input
/// is not a valid ISO 8601 date string.
///
/// # Examples
///
/// ```oxide
/// let date = parseIsoDate("2024-03-15")
/// assert!(date.isSome())
///
/// let invalid = parseIsoDate("not a date")
/// assert!(invalid.isNone())
/// ```
public fn parseIsoDate(input: &str): DateTime? {
// Implementation
}
Summary
- Use
//for line comments (most common) - Use
/* */for block comments (can be nested) - Use
///to document the following item - Use
//!to document the containing module - Documentation comments support Markdown
- Follow standard sections: Arguments, Returns, Errors, Panics, Examples
- Comment the "why", not the "what"
- Keep comments synchronized with code
Good documentation makes your code more maintainable and helps others (including your future self) understand your intent.
Control Flow
Control flow constructs let you run code conditionally or repeatedly. Oxide provides several ways to control execution: if expressions, match expressions, guard statements, and various loops. While the semantics match Rust exactly, some syntax is designed to be more approachable.
if Expressions
An if expression lets you branch your code based on conditions:
fn main() {
let number = 7
if number < 5 {
println!("condition was true")
} else {
println!("condition was false")
}
}
The condition must be a Bool. Unlike some languages, Oxide won't automatically convert non-Boolean types:
fn main() {
let number = 3
// This won't compile!
if number { // Error: expected Bool, found Int
println!("number was three")
}
// This works:
if number != 0 {
println!("number was not zero")
}
}
Multiple Conditions with else if
Chain conditions with else if:
fn main() {
let number = 6
if number % 4 == 0 {
println!("number is divisible by 4")
} else if number % 3 == 0 {
println!("number is divisible by 3")
} else if number % 2 == 0 {
println!("number is divisible by 2")
} else {
println!("number is not divisible by 4, 3, or 2")
}
}
Using if in a let Statement
Because if is an expression, you can use it on the right side of a let:
fn main() {
let condition = true
let number = if condition { 5 } else { 6 }
println!("The value of number is: \(number)")
}
Both branches must return the same type:
fn main() {
let condition = true
// This won't compile!
let number = if condition { 5 } else { "six" } // Error: incompatible types
}
if let for Pattern Matching
The if let syntax combines pattern matching with conditionals. It's especially useful for nullable types:
fn main() {
let maybeNumber: Int? = Some(42)
// Auto-unwrap: the Some() wrapper is implicit for T?
if let number = maybeNumber {
println!("Got number: \(number)")
}
// Explicit Some() also works
if let Some(number) = maybeNumber {
println!("Got number: \(number)")
}
// With else branch
if let value = maybeNumber {
println!("Value: \(value)")
} else {
println!("No value present")
}
}
Rust comparison: In Oxide, if let x = nullable automatically wraps the pattern in Some() when the right-hand side is a nullable type. In Rust, you must always write if let Some(x) = nullable.
#![allow(unused)] fn main() { // Rust requires explicit Some() if let Some(number) = maybe_number { println!("Got number: {}", number); } }
guard Statements
The guard statement is for early returns when conditions aren't met. The else block must diverge (return, break, continue, or panic):
fn processUser(user: User?): Result<String, Error> {
guard let user = user else {
return Err(anyhow!("User not found"))
}
// `user` is now available and non-null
guard user.isActive else {
return Err(anyhow!("User is not active"))
}
// We know user is active here
Ok("Processing \(user.name)")
}
guard is particularly useful for validation at the start of functions:
fn divide(a: Int, b: Int): Result<Int, String> {
guard b != 0 else {
return Err("Cannot divide by zero".toString())
}
Ok(a / b)
}
fn processItems(items: Vec<Item>): Result<Summary, Error> {
guard !items.isEmpty() else {
return Err(anyhow!("No items to process"))
}
// Continue with non-empty items...
Ok(summarize(items))
}
Rust comparison: Oxide's guard let x = expr else { } is equivalent to Rust's let Some(x) = expr else { }. The guard condition else { } form is similar to an inverted if.
#![allow(unused)] fn main() { // Rust let Some(user) = user else { return Err(anyhow!("User not found")); }; // Or for conditions: if items.is_empty() { return Err(anyhow!("No items")); } }
match Expressions
The match expression compares a value against a series of patterns. Oxide uses -> for match arms (instead of Rust's =>) and else for the wildcard pattern (instead of _):
fn main() {
let number = 3
match number {
1 -> println!("one"),
2 -> println!("two"),
3 -> println!("three"),
else -> println!("something else"),
}
}
Matching Multiple Patterns
Use | to match multiple values:
fn main() {
let number = 2
match number {
1 | 2 -> println!("one or two"),
3 -> println!("three"),
else -> println!("other"),
}
}
Matching Ranges
Use ..= for inclusive ranges:
fn main() {
let number = 7
match number {
1..=5 -> println!("one through five"),
6..=10 -> println!("six through ten"),
else -> println!("something else"),
}
}
Matching with Guards
Add conditions to patterns with if:
fn main() {
let pair = (2, -2)
match pair {
(x, y) if x == y -> println!("twins"),
(x, y) if x + y == 0 -> println!("opposites"),
(x, _) if x % 2 == 0 -> println!("first is even"),
else -> println!("no match"),
}
}
Matching Enums
Match is essential for working with enums:
enum Status {
Active,
Inactive,
Pending { reason: String },
}
fn describeStatus(status: Status): String {
match status {
Status.Active -> "User is active".toString(),
Status.Inactive -> "User is inactive".toString(),
Status.Pending { reason: r } -> "Pending: \(r)".toString(),
}
}
Rust comparison: Oxide uses dot notation (Status.Active) for enum variants. Rust's double colon syntax (Status::Active) does not exist in Oxide and will cause a syntax error.
Matching Nullable Types
Use null in pattern position to match None:
fn describe(value: Int?): String {
match value {
Some(n) if n > 0 -> "positive: \(n)".toString(),
Some(n) if n < 0 -> "negative: \(n)".toString(),
Some(0) -> "zero".toString(),
null -> "no value".toString(),
}
}
Match as Expression
Like if, match is an expression and returns a value:
fn main() {
let number = 3
let description = match number {
1 -> "one",
2 -> "two",
3 -> "three",
else -> "many",
}
println!("Number is \(description)")
}
Multi-Statement Arms
Use blocks for complex match arms:
fn process(value: Int): String {
match value {
0 -> "zero".toString(),
n if n > 0 -> {
let doubled = n * 2
let squared = n * n
"positive: doubled=\(doubled), squared=\(squared)".toString()
},
else -> {
println!("Warning: negative value")
"negative".toString()
},
}
}
Loops
Oxide provides three loop constructs: loop, while, and for.
Infinite Loops with loop
The loop keyword creates an infinite loop:
fn main() {
var counter = 0
loop {
counter += 1
println!("Count: \(counter)")
if counter >= 5 {
break
}
}
}
Returning Values from Loops
You can return a value from a loop using break:
fn main() {
var counter = 0
let result = loop {
counter += 1
if counter == 10 {
break counter * 2
}
}
println!("Result: \(result)") // Prints: Result: 20
}
Loop Labels
Use labels to break or continue outer loops:
fn main() {
var count = 0
'outer: loop {
println!("count = \(count)")
var remaining = 10
loop {
println!("remaining = \(remaining)")
if remaining == 9 {
break
}
if count == 2 {
break 'outer
}
remaining -= 1
}
count += 1
}
println!("End count = \(count)")
}
Conditional Loops with while
Execute code while a condition is true:
fn main() {
var number = 3
while number != 0 {
println!("\(number)!")
number -= 1
}
println!("LIFTOFF!")
}
while let for Conditional Pattern Matching
Similar to if let, but loops while the pattern matches:
fn main() {
var stack: Vec<Int> = vec![1, 2, 3]
while let value = stack.pop() {
println!("Popped: \(value)")
}
}
Iterating with for
The for loop iterates over collections:
fn main() {
let numbers = [10, 20, 30, 40, 50]
for number in numbers {
println!("Value: \(number)")
}
}
Iterating with Ranges
fn main() {
// 1 to 4 (exclusive end)
for i in 1..5 {
println!("\(i)")
}
// 1 to 5 (inclusive end)
for i in 1..=5 {
println!("\(i)")
}
// Reverse order
for i in (1..=5).rev() {
println!("\(i)")
}
}
Iterating with Index
Use enumerate() to get both index and value:
fn main() {
let names = vec!["Alice", "Bob", "Charlie"]
for (index, name) in names.iter().enumerate() {
println!("\(index): \(name)")
}
}
Loop Control
Use break and continue to control loop execution:
fn main() {
for i in 1..=10 {
if i == 3 {
continue // Skip 3
}
if i == 8 {
break // Stop at 8
}
println!("\(i)")
}
}
Combining Control Flow
Control flow constructs can be combined for complex logic:
fn processUsers(users: Vec<User>): Vec<String> {
var results: Vec<String> = vec![]
for user in users.iter() {
// Skip inactive users
guard user.isActive else {
continue
}
// Handle different user types
let message = match user.role {
Role.Admin -> "Admin: \(user.name)".toString(),
Role.Moderator -> "Mod: \(user.name)".toString(),
Role.User -> {
if let email = user.email {
"User: \(user.name) <\(email)>".toString()
} else {
"User: \(user.name)".toString()
}
},
}
results.push(message)
}
results
}
Summary
| Construct | Purpose | Oxide Syntax |
|---|---|---|
if | Conditional branching | if cond { } else { } |
if let | Pattern match + conditional | if let x = nullable { } |
guard | Early return on failure | guard cond else { return } |
match | Multi-way pattern matching | match x { P -> e, else -> d } |
loop | Infinite loop | loop { } |
while | Conditional loop | while cond { } |
while let | Pattern match loop | while let x = iter.next() { } |
for | Iteration | for item in collection { } |
Key Oxide differences from Rust:
- Match arms use
->(Rust's=>is invalid in Oxide) - Match wildcard is
else(Rust's_can still be used, butelseis idiomatic) guardprovides clean early-return syntaxif let x = nullableauto-unwraps withoutSome()- Enum variants use dot notation (
Enum.Variant); Rust's::syntax does not exist in Oxide
These constructs give you precise control over program flow while maintaining Oxide's goal of being approachable and readable.
What is Ownership?
Ownership is the defining feature of Oxide. It's the system that allows Oxide to make memory safety guarantees without needing a garbage collector. Understanding ownership is crucial to becoming proficient in Oxide. In this chapter, we'll explore ownership and related features that help manage memory automatically.
Oxide inherits Rust's brilliant ownership system with one simple observation: Rust was right. Rather than reinvent the wheel, Oxide provides Oxide's familiar syntax while keeping ownership semantics identical to Rust. This means you get the same safety guarantees, the same zero-cost abstractions, and the same performance—just with a syntax that feels more natural if you're coming from Swift, Kotlin, or TypeScript.
The Ownership Rules
Oxide has three fundamental ownership rules:
- Each value in Oxide has a variable that is its owner
- There can only be one owner at a time
- When the owner goes out of scope, the value is dropped (freed)
These rules prevent memory leaks and use-after-free bugs at compile time. Let's explore each with examples.
Variable Scope
A scope is the range within a program where an item is valid. Consider this example:
fn main() {
{ // s is not yet declared
let s = "hello" // s is valid from this point forward
println!("\(s)") // s is still valid
} // this scope is now over, and s is no longer valid
// println!("\(s)") // Error: s is out of scope
}
When s comes into scope, it is valid. It remains valid until it goes out of scope. At that point, the string's memory is automatically freed.
The String Type and the Move Semantic
Let's look at how ownership works with dynamically allocated data. We'll use String as our example because it's more interesting than the &str literals we've seen so far:
fn main() {
let s1 = String.from("hello")
let s2 = s1 // s1's data is MOVED to s2
// println!("\(s1)") // Error: s1 no longer owns the data
println!("\(s2)") // OK: s2 owns the data
}
When we assign s1 to s2, the ownership transfers. This is different from copying the data—it's moving ownership. s1 is no longer valid, and s2 owns the string data.
Why move instead of copy? Moving is Oxide's way of ensuring that only one owner exists at a time. This prevents multiple parts of your code from trying to manage the same memory, which would be a recipe for bugs.
Here's what happens in memory:
s1is created and points to allocated memory containing "hello"s2 = s1moves the pointer, length, and capacity froms1tos2s1is invalidated (its ownership is lost)- When
s2goes out of scope, the memory is freed
This is sometimes called a "shallow copy" in other languages, but Oxide goes further by making the original binding invalid. There's no risk of both variables trying to free the same memory.
Ownership and Functions
The same ownership rules apply when passing values to functions:
fn main() {
let s = String.from("hello")
takesOwnership(s)
// println!("\(s)") // Error: s has been moved into the function
}
fn takesOwnership(someString: String) {
println!("\(someString)")
} // someString goes out of scope and the string is dropped
When s is passed to takesOwnership, ownership of the string is transferred to the function's parameter. Once the function returns, the string is dropped.
If you want to use s after calling the function, you need to get the ownership back:
fn main() {
let s = String.from("hello")
let s = takesAndReturnsOwnership(s)
println!("\(s)") // OK: we got ownership back
}
fn takesAndReturnsOwnership(someString: String): String {
someString // ownership is returned
}
This pattern—taking ownership and returning it—is cumbersome. This is why Oxide has references and borrowing, which we'll explore in the next section. For now, understand that ownership follows these simple rules everywhere in your code.
Ownership and Copying
Some types in Oxide are simple enough that their values can be copied bit-by-bit without issues. These types implement the Copy trait. If a type implements Copy, ownership is not moved—the value is copied instead:
fn main() {
let x = 5
let y = x // x is COPIED, not moved
println!("x = \(x), y = \(y)") // Both are valid!
}
Integer types, floating-point numbers, booleans, and characters all implement Copy because they're small and live on the stack. More complex types like String do not implement Copy because their data lives on the heap.
As a rule of thumb:
- Stack types (integers, floats, bools, chars) implement
Copy - Heap types (strings, vectors, collections) do NOT implement
Copy
Implicit Returns and Ownership
In Oxide, the last expression in a function is the return value. This interacts with ownership in an important way:
fn createString(): String {
let s = String.from("hello")
s // ownership is transferred to the caller
}
fn main() {
let result = createString()
println!("\(result)") // OK: createString returned ownership
}
The value s goes out of scope at the end of createString, but because it's being returned, ownership is transferred to the caller. The memory is not dropped.
Why Ownership Matters
Oxide's ownership system provides several critical benefits:
- Memory Safety: Ownership prevents use-after-free and double-free bugs
- No Garbage Collector: Memory is freed automatically without runtime overhead
- No Runtime Errors: Memory errors are caught at compile time, not in production
- Zero-Cost Abstractions: The safety checks have no runtime cost
The elegance of Rust's ownership system is that it aligns with how we actually think about resources. When a function takes ownership, it's clear that the function is responsible for that resource. When it returns something, it transfers responsibility to the caller. This natural model prevents accidental resource leaks.
Rust's Ownership: The Gold Standard
Rust's ownership system is considered one of the greatest achievements in programming language design. It solved a problem that plagued systems programming for decades: how to provide memory safety without garbage collection. Oxide embraces this system completely.
If you've used languages with garbage collectors, this might feel unfamiliar at first. But once you understand the rules, you'll find that ownership makes code clearer and safer. You're not fighting the language—the language is helping you express your intent precisely.
Summary
- Each value has one owner at a time
- Ownership transfers when assigning to a new variable or passing to a function
- When the owner goes out of scope, the value is dropped (memory is freed)
- Types that implement
Copyare copied instead of moved - The ownership rules are the same as Rust—we kept what worked perfectly
The ownership system might seem strict, but it's what makes Oxide safe by default. In the next section, we'll learn about references and borrowing, which lets you use data without taking ownership of it.
References and Borrowing
Ownership is powerful, but constantly moving values in and out of functions gets tedious. Fortunately, Oxide has a feature for using values without transferring ownership: references.
A reference allows you to refer to a value without taking ownership of it. Instead of passing the value itself, you pass a reference to it. When the function returns, ownership remains with the original owner.
Creating and Using References
You create a reference using the ampersand (&) operator:
fn main() {
let s = String.from("hello")
let length = calculateLength(&s)
println!("'{}' has length {}", s, length) // s is still valid!
}
fn calculateLength(s: &String): UIntSize {
s.len()
} // s goes out of scope, but it doesn't own the String, so nothing happens
The &s syntax creates a reference to s. The calculateLength function receives a reference (&String) instead of the string itself. Since calculateLength doesn't own the string, the string is not dropped when the function returns.
Notice that we can still use s after calling calculateLength. The original owner retains ownership; we only loaned it to the function.
Mutable References
By default, references are immutable. If you try to modify the data through a reference, the compiler stops you:
fn main() {
let s = String.from("hello")
// changeString(&s) // Error: cannot mutate through immutable reference
changeString(&mut s) // Error: s is immutable
}
fn changeString(s: &String) {
// s.push_str(" world") // Error: cannot mutate through immutable reference
}
To modify data through a reference, you need a mutable reference, declared with &mut:
fn main() {
var s = String.from("hello")
changeString(&mut s)
println!("\(s)") // "hello world"
}
fn changeString(s: &mut String) {
s.push_str(" world")
}
Important: The original binding must be mutable (var s) to allow mutable references. You cannot create a mutable reference to an immutable binding.
The Rules of Borrowing
Oxide's borrow checker enforces two critical rules:
- Either multiple immutable references OR one mutable reference at a time
- References must always be valid (no use-after-free)
Let's explore these rules with examples.
Multiple Immutable References
You can have multiple immutable references to the same data:
fn main() {
let s = String.from("hello")
let r1 = &s
let r2 = &s
let r3 = &s
println!("\(r1), \(r2), \(r3)") // All three references work fine
}
This is safe because all readers are immutable. Multiple readers cannot corrupt data.
Immutable and Mutable References Cannot Coexist
Once you create a mutable reference, you cannot have any immutable references:
fn main() {
var s = String.from("hello")
let r1 = &s
let r2 = &s
// var r3 = &mut s // Error: cannot borrow as mutable while already borrowed as immutable
println!("\(r1), \(r2)")
}
The compiler prevents the mutable reference because r1 and r2 are still in use. If we allowed &mut s, code using r1 or r2 might suddenly see the data change, which would be surprising and dangerous.
Using Scope to Release Borrows
A borrow ends when the reference is last used, not necessarily when it goes out of scope:
fn main() {
var s = String.from("hello")
let r1 = &s
let r2 = &s
println!("\(r1), \(r2)") // Last use of r1 and r2
// r1 and r2 are no longer needed after this point
var r3 = &mut s // OK: r1 and r2's borrows have ended
r3.push_str(" world")
println!("\(r3)")
}
This is called non-lexical lifetimes (NLL). Rust introduced this feature to make borrowing less restrictive. Oxide inherits it, which means you often get mutable access sooner than you might expect.
Mutable References Are Exclusive
Only one mutable reference can exist at a time:
fn main() {
var s = String.from("hello")
var r1 = &mut s
// var r2 = &mut s // Error: cannot have two mutable references
r1.push_str(" world")
println!("\(r1)")
}
This rule prevents data races and ensures that if you modify data, no other code can see it in an inconsistent state.
The &str Lightweight Reference
We've seen &str in function parameters. This is a reference to a string slice, which we'll explore in the next section. For now, understand that &str borrows a string—it's lighter weight than &String because it doesn't own any heap allocation:
fn main() {
let s = String.from("hello world")
let word = firstWord(&s)
println!("\(word)") // "hello"
}
fn firstWord(s: &str): &str {
let bytes = s.as_bytes()
for (i, &item) in bytes.iter().enumerate() {
if item == ' ' {
return &s[0..i]
}
}
&s[..]
}
Using &str is more flexible than &String because it accepts both String references and string literals.
Why Borrowing Matters
The borrowing system solves the ownership problem we encountered earlier. Instead of constantly moving values and returning them, you can borrow them:
// Without borrowing: tedious
fn main() {
let s1 = String.from("hello")
let (s1, len) = calculateLength(s1)
println!("'{}' has length {}", s1, len)
}
fn calculateLength(s: String): (String, UIntSize) {
let length = s.len()
(s, length)
}
// With borrowing: clean
fn main() {
let s1 = String.from("hello")
let len = calculateLength(&s1)
println!("'{}' has length {}", s1, len)
}
fn calculateLength(s: &String): UIntSize {
s.len()
}
Borrowing lets functions use data without taking responsibility for it.
Mutable References Enable Controlled Mutation
Mutable references are Oxide's way of saying "this function needs to modify this data." They make your code's intent clear:
fn main() {
var user = User { name: "Alice".toString() }
updateUserName(&mut user, "Bob")
println!("\(user.name)") // "Bob"
}
fn updateUserName(user: &mut User, newName: &str) {
user.name = newName.toString()
}
The &mut syntax makes it obvious that the function will modify the argument. This is much clearer than passing a regular parameter and having side effects.
Lifetime Annotations
Sometimes the compiler needs you to explicitly specify how long a reference is valid. This is called a lifetime. We'll explore lifetimes in depth in a later chapter, but here's a simple example:
fn longest<'a>(x: &'a str, y: &'a str): &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let s1 = "short"
let s2 = "a much longer string"
let result = longest(s1, s2)
println!("\(result)")
}
The 'a notation tells the compiler that the returned reference lives as long as both input references. This ensures the return value is always valid.
You don't need to understand lifetimes yet. Just know that Oxide sometimes requires you to be explicit about how long references live, which is another safety feature.
Rust Comparison
The referencing and borrowing system is identical to Rust. The same &T and &mut T syntax, the same rules, the same benefits:
#![allow(unused)] fn main() { // Rust - identical to Oxide fn calculate_length(s: &String) -> usize { s.len() } fn change_string(s: &mut String) { s.push_str(" world"); } }
Summary
- References allow using values without taking ownership
- Immutable references (
&T) are the default - Mutable references (
&mut T) allow modification, with restrictions - Oxide enforces: either many immutable references OR one mutable reference
- Borrows end when the reference is no longer used (non-lexical lifetimes)
- References prevent data races and use-after-free bugs at compile time
- Lifetime annotations sometimes explicit about how long references live
Borrowing is one of Oxide's most powerful features. It lets you express ownership clearly while remaining flexible about how data flows through your program. Combined with ownership, it enables memory safety without garbage collection—the same achievement Rust pioneered.
In the next section, we'll explore a special kind of reference: slices.
The Slice Type
A slice is a reference to a contiguous sequence of elements in a collection. Unlike references to the whole collection, slices let you reference a specific portion of it. Slices are incredibly useful and appear throughout Oxide code.
String Slices
A string slice is a reference to part of a String:
fn main() {
let s = String.from("hello world")
let hello = &s[0..5]
let world = &s[6..11]
println!("\(hello)") // "hello"
println!("\(world)") // "world"
}
Rather than taking a reference to the entire string, &s[0..5] creates a reference to a portion of the string. The range syntax [starting_index..ending_index] includes starting_index but excludes ending_index.
Rust comparison: String slices work identically to Rust. The type annotation is &str.
#![allow(unused)] fn main() { // Rust - identical syntax let s = String::from("hello world"); let hello = &s[0..5]; let world = &s[6..11]; }
Slice Shorthand Syntax
You can omit the starting index if it's 0 or the ending index if it's the length:
fn main() {
let s = String.from("hello")
let slice1 = &s[0..2] // "he"
let slice2 = &s[..2] // "he" - same, omit start
let slice3 = &s[3..5] // "lo"
let slice4 = &s[3..] // "lo" - same, omit end
let slice5 = &s[..] // "hello" - entire string
}
This shorthand makes slice syntax less verbose for common cases.
Using Slices in Functions
Slices are particularly useful in function parameters because they're more flexible than &String:
fn firstWord(s: &str): &str {
let bytes = s.as_bytes()
for (i, &item) in bytes.iter().enumerate() {
if item == ' ' as u8 {
return &s[0..i]
}
}
&s[..]
}
fn main() {
let myString = String.from("hello world")
let word = firstWord(&myString)
println!("\(word)") // "hello"
// Also works with string literals
let word = firstWord("hello world")
println!("\(word)") // "hello"
}
Notice that firstWord takes &str, not &String. This makes it more flexible. We can pass:
- A reference to a
String:&myString(automatically coerced to&str) - A string literal:
"hello world"(which is already&str)
If the function required &String, we couldn't pass string literals directly.
The Power of &str Over &String
Using &str in your API makes your code more flexible:
// Restrictive: only accepts String references
fn processBad(s: &String): UIntSize {
s.len()
}
// Flexible: accepts string slices and String references
fn processGood(s: &str): UIntSize {
s.len()
}
fn main() {
let myString = String.from("hello")
let literal = "world"
// processBad(literal) // Error: literal is &str, not &String
processGood(&myString) // OK: &String coerces to &str
processGood(literal) // OK: already &str
}
This is a key principle in Oxide and Rust: prefer &str over &String, &[T] over &Vec<T>, etc. Your APIs are more useful when they accept slices rather than owned collections.
Array Slices
Slices work with arrays and vectors, not just strings:
fn main() {
let arr = [1, 2, 3, 4, 5]
// Slice part of the array
let slice = &arr[1..4] // [2, 3, 4]
// Iterate over the slice
for item in slice {
println!("\(item)")
}
}
With vectors, slices are even more useful:
fn main() {
let v = vec![1, 2, 3, 4, 5]
let slice = &v[2..] // [3, 4, 5]
println!("Length of slice: \(slice.len())")
for &item in slice {
println!("\(item)")
}
}
Slices and Borrowing
Slices respect the borrowing rules. You cannot create a mutable reference to a collection while slices exist:
fn main() {
var s = String.from("hello world")
let word = firstWord(&s) // immutable borrow
// s.clear() // Error: cannot borrow as mutable while borrowed as immutable
println!("\(word)") // word's borrow ends here
s.clear() // OK now: word is no longer used
}
fn firstWord(s: &str): &str {
let bytes = s.as_bytes()
for (i, &item) in bytes.iter().enumerate() {
if item == ' ' as u8 {
return &s[0..i]
}
}
&s[..]
}
This prevents a common bug: modifying a collection while holding a reference to its contents. The slice is only valid as long as the underlying data doesn't change.
The Slice Type Syntax
The type of a slice is &[T], where T is the element type:
fn main() {
let s = String.from("hello")
let slice: &str = &s[..] // &str is shorthand for &[char] (roughly)
let arr = [1, 2, 3]
let slice: &[Int] = &arr[..] // &[Int] slice
let v = vec![1, 2, 3]
let slice: &[Int] = &v[..] // &[Int] slice from vector
}
The syntax &[T] represents "a reference to a contiguous sequence of T". Notice that &str is special—it's the slice type for strings, optimized for UTF-8 text.
Practical Example: Splitting Words
Let's build something practical. A function that splits a string into words and returns an array of slices:
fn splitWords(s: &str): Vec<&str> {
var words = vec![]
var start = 0
for (i, &c) in s.as_bytes().iter().enumerate() {
if c == ' ' as u8 {
words.push(&s[start..i])
start = i + 1
}
}
if start < s.len() {
words.push(&s[start..])
}
words
}
fn main() {
let sentence = "the quick brown fox"
let words = splitWords(sentence)
for word in words {
println!("Word: \(word)")
}
}
Output:
Word: the
Word: quick
Word: brown
Word: fox
This function takes a string slice, and returns a vector of slices pointing into the original string. No data is copied—only references are created.
Why Slices Are Important
Slices embody three key principles:
- Zero-Cost Abstractions: Slices have no runtime overhead. They're just a pointer and length.
- Safety: Slices prevent out-of-bounds access at compile time.
- Flexibility: Functions accepting slices work with owned collections, string literals, and stack arrays.
Because of these properties, slices appear everywhere in Oxide code. They're the idiomatic way to work with sequences.
Common Slice Methods
Slices provide useful methods for working with sequences:
fn main() {
let v = vec![1, 2, 3, 4, 5]
let slice = &v[1..4] // [2, 3, 4]
// Length
println!("Length: \(slice.len())") // 3
// Access elements
println!("First: \(slice[0])") // 2
println!("Last: \(slice[2])") // 4
// Iteration
for &item in slice {
println!("Item: \(item)")
}
// Check if empty
if slice.is_empty() {
println!("Empty slice")
} else {
println!("Not empty")
}
}
Summary
- Slices are references to contiguous portions of collections
- String slices are denoted
&str; array/vector slices are&[T] - Create slices with range syntax:
&collection[start..end] - Use shorthand:
&s[..]for the whole collection,&s[..n]to omit end, etc. - Slices are more flexible than references to whole collections; prefer them in APIs
- Slices respect borrowing rules: no modification while slices exist
- Slices have zero runtime cost and no bounds checking overhead
Slices combine safety with flexibility. They're one of the features that makes Oxide (and Rust) pleasant to use. By understanding ownership, references, and slices, you now have the foundation to write safe, efficient code.
The ownership system—ownership itself, borrowing, and slices—is complete. These three concepts form the bedrock of Oxide's memory safety story.
Defining and Instantiating Structs
Structs are one of the fundamental ways to create custom types in Oxide. A struct, short for "structure," lets you package together related values under a single name. If you're coming from object-oriented languages, a struct is similar to a class's data attributes.
Defining Structs
To define a struct, use the struct keyword followed by the struct name and curly
braces containing the field definitions. Each field has a name and a type, using
Oxide's camelCase naming convention for field names.
struct User {
active: Bool,
username: String,
email: String,
signInCount: UInt64,
}
Notice that:
- Field names use camelCase (e.g.,
signInCount, notsign_in_count) - Types use Oxide's type aliases (
Bool,UInt64) or standard types (String) - The struct body uses curly braces, just like Rust
Public Structs and Fields
To make a struct accessible from other modules, use the public keyword:
public struct User {
public active: Bool,
public username: String,
email: String, // Private by default
signInCount: UInt64, // Private by default
}
The public keyword replaces Rust's pub for visibility. You can apply it to
both the struct itself and individual fields.
Creating Instances
To create an instance of a struct, specify the struct name followed by curly braces containing the field values:
fn main() {
let user = User {
active: true,
username: "alice123".toString(),
email: "alice@example.com".toString(),
signInCount: 1,
}
}
The order of fields doesn't matter when creating an instance, but all fields must be provided (unless they have default values through other mechanisms).
Mutable Instances
If you need to modify a struct instance after creation, use var instead of let:
fn main() {
var user = User {
active: true,
username: "alice123".toString(),
email: "alice@example.com".toString(),
signInCount: 1,
}
// Now we can modify fields
user.email = "newemail@example.com".toString()
user.signInCount += 1
}
Note that in Oxide, the entire instance must be mutable to change any field. You cannot mark only certain fields as mutable.
Accessing Field Values
Use dot notation to access struct fields:
fn main() {
let user = User {
active: true,
username: "alice123".toString(),
email: "alice@example.com".toString(),
signInCount: 1,
}
println!("Username: \(user.username)")
println!("Email: \(user.email)")
println!("Sign-in count: \(user.signInCount)")
}
Oxide's string interpolation with \(expression) makes it easy to embed field
values directly in output strings.
Field Init Shorthand
When variable names match field names, you can use the shorthand syntax:
fn createUser(username: String, email: String): User {
User {
active: true,
username, // Same as username: username
email, // Same as email: email
signInCount: 1,
}
}
This shorthand reduces repetition when the parameter names match the field names.
Struct Update Syntax
When creating a new instance that reuses most values from an existing instance,
use the struct update syntax with ..:
fn main() {
let user1 = User {
active: true,
username: "alice123".toString(),
email: "alice@example.com".toString(),
signInCount: 1,
}
let user2 = User {
email: "bob@example.com".toString(),
..user1 // Use remaining fields from user1
}
// user2 has bob's email but alice's username, active status, and signInCount
}
The ..user1 must come last in the struct literal. It copies all remaining fields
from the source instance.
Important ownership note: The struct update syntax moves data from the source.
After the update, user1 cannot be used if any of its fields were moved (like
String fields). However, if only copyable types (like Bool or UInt64) are
transferred, the source remains valid.
Tuple Structs
Oxide supports tuple structs, which are structs without named fields. These are useful when you want to give a tuple a distinct type name:
struct Color(Int, Int, Int)
struct Point(Int, Int, Int)
fn main() {
let black = Color(0, 0, 0)
let origin = Point(0, 0, 0)
// Access fields by index
let red = black.0
let green = black.1
let blue = black.2
}
Even though Color and Point have the same field types, they are different
types. A function expecting a Color won't accept a Point.
Unit-Like Structs
You can also define structs with no fields, called unit-like structs:
struct AlwaysEqual
fn main() {
let subject = AlwaysEqual
}
Unit-like structs are useful when you need to implement a trait on a type but don't need to store any data.
Adding Attributes
Structs commonly use derive attributes to automatically implement traits:
#[derive(Debug, Clone, PartialEq)]
public struct User {
active: Bool,
username: String,
email: String,
signInCount: UInt64,
}
The #[derive(...)] attribute automatically generates implementations for common
traits:
Debug: Enables printing with{:?}format specifierClone: Enables creating deep copies with.clone()PartialEq: Enables comparison with==and!=
Complete Example
Here's a complete example showing struct definition and usage:
#[derive(Debug, Clone)]
public struct Rectangle {
width: Int,
height: Int,
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
}
println!("Rectangle: {:?}", rect)
println!("Width: \(rect.width)")
println!("Height: \(rect.height)")
// Create a modified copy
let wider = Rectangle {
width: 60,
..rect
}
println!("Wider rectangle: {:?}", wider)
}
Rust Comparison
| Aspect | Rust | Oxide |
|---|---|---|
| Visibility | pub | public |
| Field naming | snake_case | camelCase |
| Struct syntax | struct { } | struct { } (same) |
| Tuple struct | struct Point(i32, i32) | struct Point(Int, Int) |
| Types | i32, bool, u64 | Int, Bool, UInt64 |
The underlying semantics are identical. Oxide structs are Rust structs with different naming conventions and type aliases. The compiled binary is exactly the same as equivalent Rust code.
Summary
Structs let you create custom types that package related data together:
- Use
structwith curly braces for named fields - Use camelCase for field names
- Use
publicfor visibility - Create instances with struct literals
- Use
varfor mutable instances - Derive common traits with
#[derive(...)]
In the next section, we'll build a complete program using structs to see how they work in practice.
An Example Program Using Structs
To understand when structs are useful, let's build a program that calculates the area of a rectangle. We'll start with simple variables and progressively refactor to use structs, demonstrating how they improve code organization and clarity.
Starting with Simple Variables
Here's a basic approach using individual variables:
fn main() {
let width = 30
let height = 50
println!(
"The area of the rectangle is \(area(width, height)) square pixels."
)
}
fn area(width: Int, height: Int): Int {
width * height
}
This works, but the area function has two parameters that conceptually belong
together. The relationship between width and height isn't explicit in the code.
Refactoring with Tuples
We can group the dimensions using a tuple:
fn main() {
let rect = (30, 50)
println!(
"The area of the rectangle is \(area(rect)) square pixels."
)
}
fn area(dimensions: (Int, Int)): Int {
dimensions.0 * dimensions.1
}
This groups the data, but now we've lost meaning. Is dimensions.0 the width or
the height? The tuple doesn't convey this information, making the code harder to
understand.
Refactoring with Structs
Structs solve this by giving meaningful names to both the type and its fields:
struct Rectangle {
width: Int,
height: Int,
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
}
println!(
"The area of the rectangle is \(area(&rect)) square pixels."
)
}
fn area(rectangle: &Rectangle): Int {
rectangle.width * rectangle.height
}
Now the code clearly shows that width and height are dimensions of a
Rectangle. The function signature area(rectangle: &Rectangle) immediately
conveys what the function operates on.
Notice that area takes a reference &Rectangle. This means:
- The function borrows the rectangle rather than taking ownership
- The original
rectremains valid after the function call - No data is copied, just a reference to the existing rectangle
Adding Debug Output
When developing, you often want to print struct values for debugging. Let's see what happens if we try to print our rectangle:
struct Rectangle {
width: Int,
height: Int,
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
}
println!("rect is \(rect)") // This won't compile!
}
This fails because Rectangle doesn't implement the Display trait that string
interpolation requires. For debugging purposes, we can use the Debug trait:
#[derive(Debug)]
struct Rectangle {
width: Int,
height: Int,
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
}
// Use {:?} for Debug formatting
println!("rect is {:?}", rect)
// Use {:#?} for pretty-printed Debug output
println!("rect is {:#?}", rect)
}
Output:
rect is Rectangle { width: 30, height: 50 }
rect is Rectangle {
width: 30,
height: 50,
}
The #[derive(Debug)] attribute automatically generates an implementation of
the Debug trait, enabling the {:?} and {:#?} format specifiers.
Using dbg! Macro
For quick debugging, the dbg! macro is even more convenient:
#[derive(Debug)]
struct Rectangle {
width: Int,
height: Int,
}
fn main() {
let scale = 2
let rect = Rectangle {
width: dbg!(30 * scale),
height: 50,
}
dbg!(&rect)
}
Output:
[src/main.ox:9:16] 30 * scale = 60
[src/main.ox:13:5] &rect = Rectangle {
width: 60,
height: 50,
}
The dbg! macro:
- Prints the file and line number
- Shows the expression being evaluated
- Returns ownership of the value (so it can be used inline)
- Outputs to stderr rather than stdout
Notice that we use dbg!(&rect) with a reference to avoid moving ownership.
Complete Working Example
Here's the complete program with all improvements:
#[derive(Debug)]
struct Rectangle {
width: Int,
height: Int,
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
}
let area = calculateArea(&rect)
println!("Rectangle: {:#?}", rect)
println!("Area: \(area) square pixels")
// Example with multiple rectangles
let rectangles = vec![
Rectangle { width: 10, height: 20 },
Rectangle { width: 30, height: 50 },
Rectangle { width: 5, height: 15 },
]
println!("\nAll rectangles:")
for rect in rectangles.iter() {
println!(" {:?} -> area: \(calculateArea(rect))", rect)
}
}
fn calculateArea(rectangle: &Rectangle): Int {
rectangle.width * rectangle.height
}
Output:
Rectangle: Rectangle {
width: 30,
height: 50,
}
Area: 1500 square pixels
All rectangles:
Rectangle { width: 10, height: 20 } -> area: 200
Rectangle { width: 30, height: 50 } -> area: 1500
Rectangle { width: 5, height: 15 } -> area: 75
Deriving Multiple Traits
In practice, you'll often derive several traits together:
#[derive(Debug, Clone, PartialEq, Eq)]
struct Rectangle {
width: Int,
height: Int,
}
fn main() {
let rect1 = Rectangle { width: 30, height: 50 }
let rect2 = rect1.clone() // Create a copy
let rect3 = Rectangle { width: 40, height: 50 }
println!("rect1 == rect2: \(rect1 == rect2)") // true
println!("rect1 == rect3: \(rect1 == rect3)") // false
}
Common derivable traits:
Debug: Enables{:?}formatting for debuggingClone: Enables.clone()for deep copyingPartialEq: Enables==and!=comparisonEq: Indicates that equality is reflexive, symmetric, and transitiveHash: Enables use as a key inHashMap
Why Use Structs?
This example demonstrates several benefits of structs:
- Semantic clarity:
Rectangleis more meaningful than(Int, Int) - Self-documenting code: Field names like
widthandheightexplain themselves - Type safety: A
Rectanglecan't be confused with other(Int, Int)tuples - Extensibility: Easy to add more fields or functionality later
- Maintainability: Changes to the struct definition are centralized
Moving Toward Methods
The calculateArea function works, but it's disconnected from the Rectangle
type. Conceptually, calculating area is something a rectangle does, not
something done to a rectangle.
In the next section, we'll learn about methods, which let us define functions that are directly associated with a struct:
extension Rectangle {
fn area(): Int {
self.width * self.height
}
}
fn main() {
let rect = Rectangle { width: 30, height: 50 }
println!("Area: \(rect.area())") // More natural!
}
This syntax, using extension blocks and implicit self, is one of Oxide's
major features and is covered in detail in the next section.
Summary
In this section, we saw how to:
- Refactor code to use structs for better organization
- Derive the
Debugtrait for printing struct values - Use
dbg!for quick debugging - Access struct fields through references
- Appreciate the benefits of structured data
Next, we'll explore method syntax to make our struct-related functions even more intuitive and powerful.
Method Syntax
Methods are functions defined within the context of a type. In Oxide, we define
methods using extension blocks, which associate functions with a struct, enum,
or trait. This is one of Oxide's major features that differs significantly from
Rust's impl blocks.
Defining Methods with Extension Blocks
Let's transform our calculateArea function from the previous section into a
method on Rectangle:
#[derive(Debug)]
struct Rectangle {
width: Int,
height: Int,
}
extension Rectangle {
fn area(): Int {
self.width * self.height
}
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
}
println!("Area: \(rect.area()) square pixels")
}
Key points:
extension Rectangle { }replaces Rust'simpl Rectangle { }- Methods have implicit access to
self - No
selfparameter is written in the method signature - Call methods with dot notation:
rect.area()
Understanding self in Oxide Methods
In Oxide, self is implicit in non-static methods. The method modifier
determines how self is accessed:
| Modifier | Self Type | Description |
|---|---|---|
| (none) | &self | Immutable borrow (default) |
mutating | &mut self | Mutable borrow |
consuming | self | Takes ownership |
static | (none) | No self parameter |
This is a fundamental difference from Rust, where you explicitly write &self,
&mut self, or self as the first parameter.
Default Methods: Immutable Borrow
When you define a method without any modifier, it receives an immutable borrow
of self:
extension Rectangle {
// Default: borrows self immutably (&self)
fn area(): Int {
self.width * self.height
}
fn perimeter(): Int {
2 * (self.width + self.height)
}
fn isSquare(): Bool {
self.width == self.height
}
}
These methods can read from self but cannot modify it. This is the most common
method type and makes sense for any operation that doesn't change the object's
state.
Rust Equivalent
The Oxide code above translates to this Rust code:
#![allow(unused)] fn main() { impl Rectangle { fn area(&self) -> i32 { self.width * self.height } fn perimeter(&self) -> i32 { 2 * (self.width + self.height) } fn is_square(&self) -> bool { self.width == self.height } } }
Mutating Methods: Mutable Borrow
Use the mutating modifier when a method needs to modify self:
extension Rectangle {
mutating fn scale(factor: Int) {
self.width *= factor
self.height *= factor
}
mutating fn setWidth(newWidth: Int) {
self.width = newWidth
}
mutating fn setHeight(newHeight: Int) {
self.height = newHeight
}
mutating fn double() {
self.scale(2) // Can call other mutating methods
}
}
fn main() {
var rect = Rectangle { width: 10, height: 20 }
println!("Before: {:?}", rect)
rect.scale(3)
println!("After scale(3): {:?}", rect)
rect.setWidth(100)
println!("After setWidth(100): {:?}", rect)
}
Output:
Before: Rectangle { width: 10, height: 20 }
After scale(3): Rectangle { width: 30, height: 60 }
After setWidth(100): Rectangle { width: 100, height: 60 }
Important notes:
- You can only call
mutatingmethods on mutable bindings (var, notlet) - The
mutatingkeyword clearly signals that the method modifies state - This pattern is inspired by Swift's
mutatingkeyword
Rust Equivalent
#![allow(unused)] fn main() { impl Rectangle { fn scale(&mut self, factor: i32) { self.width *= factor; self.height *= factor; } fn set_width(&mut self, new_width: i32) { self.width = new_width; } } }
Consuming Methods: Taking Ownership
Use the consuming modifier when a method takes ownership of self:
extension Rectangle {
consuming fn intoSquare(): Rectangle {
let size = (self.width + self.height) / 2
Rectangle { width: size, height: size }
}
consuming fn decompose(): (Int, Int) {
(self.width, self.height)
}
consuming fn destroy() {
// self is dropped at the end of this method
println!("Rectangle destroyed!")
}
}
fn main() {
let rect = Rectangle { width: 30, height: 50 }
let (w, h) = rect.decompose()
println!("Width: \(w), Height: \(h)")
// rect can no longer be used - ownership was consumed
// println!("{:?}", rect) // ERROR: value moved
}
Consuming methods are used when:
- Transforming a value into something else
- Extracting owned data from a struct
- Intentionally consuming a resource (like closing a file handle)
The naming convention often uses "into" prefix (like intoSquare) to signal
that the original value will be consumed.
Rust Equivalent
#![allow(unused)] fn main() { impl Rectangle { fn into_square(self) -> Rectangle { let size = (self.width + self.height) / 2; Rectangle { width: size, height: size } } fn decompose(self) -> (i32, i32) { (self.width, self.height) } } }
Static Methods: No Self Parameter
Use the static modifier for functions that don't operate on an instance:
extension Rectangle {
static fn new(width: Int, height: Int): Rectangle {
Rectangle { width, height }
}
static fn square(size: Int): Rectangle {
Rectangle { width: size, height: size }
}
static fn zero(): Rectangle {
Rectangle { width: 0, height: 0 }
}
static fn fromDimensions(dimensions: (Int, Int)): Rectangle {
Rectangle {
width: dimensions.0,
height: dimensions.1,
}
}
}
fn main() {
let rect1 = Rectangle.new(30, 50)
let rect2 = Rectangle.square(25)
let rect3 = Rectangle.zero()
println!("rect1: {:?}", rect1)
println!("rect2: {:?}", rect2)
println!("rect3: {:?}", rect3)
}
Static methods are called on the type itself using dot notation:
Rectangle.new(30, 50) rather than rect.new(30, 50).
Common uses for static methods:
- Constructors (like
new,default,fromXxx) - Factory methods that create instances
- Utility functions related to the type
Using Self in Static Methods
Inside an extension block, Self (capital S) refers to the type being extended:
extension Rectangle {
static fn square(size: Int): Self {
Self { width: size, height: size }
}
static fn default(): Self {
Self.zero() // Can call other static methods
}
}
Rust Equivalent
#![allow(unused)] fn main() { impl Rectangle { fn new(width: i32, height: i32) -> Rectangle { Rectangle { width, height } } fn square(size: i32) -> Self { Self { width: size, height: size } } } }
Note: In Rust, these are called "associated functions" when they don't take
self. Oxide uses static to make this explicit.
Methods with Additional Parameters
Methods can take additional parameters beyond the implicit self:
extension Rectangle {
fn canHold(other: &Rectangle): Bool {
self.width > other.width && self.height > other.height
}
mutating fn resizeTo(width: Int, height: Int) {
self.width = width
self.height = height
}
fn areaRatio(other: &Rectangle): Float {
self.area() as Float / other.area() as Float
}
}
fn main() {
let rect1 = Rectangle { width: 30, height: 50 }
let rect2 = Rectangle { width: 10, height: 20 }
println!("rect1 can hold rect2: \(rect1.canHold(&rect2))")
println!("Area ratio: \(rect1.areaRatio(&rect2))")
}
Multiple Extension Blocks
You can split methods across multiple extension blocks:
struct Rectangle {
width: Int,
height: Int,
}
// Constructors
extension Rectangle {
static fn new(width: Int, height: Int): Self {
Self { width, height }
}
static fn square(size: Int): Self {
Self { width: size, height: size }
}
}
// Geometry calculations
extension Rectangle {
fn area(): Int {
self.width * self.height
}
fn perimeter(): Int {
2 * (self.width + self.height)
}
}
// Mutations
extension Rectangle {
mutating fn scale(factor: Int) {
self.width *= factor
self.height *= factor
}
}
This helps organize methods by category, though it's also fine to keep everything in a single block.
Implementing Traits with Extension Blocks
Extension blocks also implement traits using the syntax extension Type: Trait:
import std.fmt.{ Display, Formatter, Result }
#[derive(Clone)]
struct Rectangle {
width: Int,
height: Int,
}
extension Rectangle: Display {
fn fmt(f: &mut Formatter): Result {
write!(f, "Rectangle(\(self.width) x \(self.height))")
}
}
fn main() {
let rect = Rectangle { width: 30, height: 50 }
println!("\(rect)") // Uses Display implementation
}
This replaces Rust's impl Trait for Type syntax. The colon reads naturally:
"extend Rectangle with Display capability."
Multiple Trait Implementations
import std.fmt.{ Display, Formatter, Result }
import std.cmp.{ Ord, Ordering }
extension Rectangle: Display {
fn fmt(f: &mut Formatter): Result {
write!(f, "\(self.width)x\(self.height)")
}
}
extension Rectangle: PartialOrd {
fn partialCmp(other: &Self): Ordering? {
self.area().partialCmp(&other.area())
}
}
Visibility in Extension Blocks
Use public to make methods accessible from other modules:
public struct Rectangle {
public width: Int,
public height: Int,
}
extension Rectangle {
public static fn new(width: Int, height: Int): Self {
Self { width, height }
}
public fn area(): Int {
self.width * self.height
}
// Private helper method
fn validate(): Bool {
self.width > 0 && self.height > 0
}
public mutating fn scale(factor: Int) {
if self.validate() {
self.width *= factor
self.height *= factor
}
}
}
Complete Example: A Point Struct
Here's a comprehensive example showing all method modifiers:
#[derive(Debug, Clone, PartialEq)]
public struct Point {
x: Float,
y: Float,
}
extension Point {
// Static: constructors
public static fn new(x: Float, y: Float): Self {
Self { x, y }
}
public static fn origin(): Self {
Self { x: 0.0, y: 0.0 }
}
public static fn fromAngle(angle: Float, distance: Float): Self {
Self {
x: distance * angle.cos(),
y: distance * angle.sin(),
}
}
// Default (&self): read-only operations
public fn distanceFromOrigin(): Float {
(self.x * self.x + self.y * self.y).sqrt()
}
public fn distanceTo(other: &Point): Float {
let dx = self.x - other.x
let dy = self.y - other.y
(dx * dx + dy * dy).sqrt()
}
public fn midpointTo(other: &Point): Point {
Point {
x: (self.x + other.x) / 2.0,
y: (self.y + other.y) / 2.0,
}
}
// Mutating (&mut self): modifications
public mutating fn translate(dx: Float, dy: Float) {
self.x += dx
self.y += dy
}
public mutating fn scale(factor: Float) {
self.x *= factor
self.y *= factor
}
public mutating fn normalize() {
let dist = self.distanceFromOrigin()
if dist != 0.0 {
self.x /= dist
self.y /= dist
}
}
// Consuming (self): ownership transfer
public consuming fn intoPolar(): (Float, Float) {
let r = self.distanceFromOrigin()
let theta = self.y.atan2(self.x)
(r, theta)
}
public consuming fn add(other: Point): Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
// Using static methods
var point = Point.new(3.0, 4.0)
let origin = Point.origin()
// Using default (immutable) methods
println!("Distance from origin: \(point.distanceFromOrigin())")
println!("Distance to origin: \(point.distanceTo(&origin))")
// Using mutating methods
point.translate(1.0, 1.0)
println!("After translate: {:?}", point)
point.scale(2.0)
println!("After scale: {:?}", point)
// Using consuming method
let polar = point.intoPolar()
println!("Polar coordinates: r=\(polar.0), theta=\(polar.1)")
// point is now consumed and cannot be used
}
Summary of Method Modifiers
| Modifier | Self Access | Use Case | Example |
|---|---|---|---|
| (none) | &self | Reading data, calculations | fn area(): Int |
mutating | &mut self | Modifying state | mutating fn scale(f: Int) |
consuming | self | Ownership transfer, transforms | consuming fn into(): T |
static | (none) | Constructors, utilities | static fn new(): Self |
Comparison with Rust
| Aspect | Rust | Oxide |
|---|---|---|
| Implementation block | impl Type { } | extension Type { } |
| Trait implementation | impl Trait for Type { } | extension Type: Trait { } |
| Immutable borrow | fn foo(&self) | fn foo() |
| Mutable borrow | fn foo(&mut self) | mutating fn foo() |
| Take ownership | fn foo(self) | consuming fn foo() |
| No self | fn foo() (associated fn) | static fn foo() |
| Method call | obj.method() | obj.method() (same) |
| Static call | Type::method() | Type.method() |
Note: Rust's
::path separator does not exist in Oxide. UsingType::method()in Oxide code will cause a syntax error. Oxide uses.as its only path separator.
The key insight is that Oxide makes the method's relationship to self explicit
through modifiers rather than through the first parameter. This makes the intent
clearer when reading method signatures.
Summary
Extension blocks are Oxide's way of adding methods to types:
- Use
extension Type { }for inherent methods - Use
extension Type: Trait { }for trait implementations - Method modifiers (
mutating,consuming,static) replace explicitselfparameters - Default methods borrow immutably (
&self) mutatingmethods can modify state (&mut self)consumingmethods take ownership (self)staticmethods have noselfand are called on the type- Use
publicfor visibility, just like with structs
This syntax makes method signatures more readable and the relationship between methods and their data more explicit, while compiling to exactly the same code as equivalent Rust.
Defining an Enum
Enums allow you to define a type by enumerating its possible variants. Where structs
give you a way of grouping together related fields and data, enums give you a way
of saying a value is one of a possible set of values. For example, we may want to
say that Shape is one of a set of possible shapes that also includes Circle and
Triangle. Oxide lets us express these possibilities as an enum.
Let's look at a situation we might want to express in code and see why enums are useful and more appropriate than structs in this case. Say we need to work with IP addresses. Currently, two major standards are used for IP addresses: version four and version six. Because these are the only possibilities for an IP address that our program will come across, we can enumerate all possible variants, which is where enumeration gets its name.
Any IP address can be either a version four or a version six address, but not both at the same time. That property of IP addresses makes the enum data structure appropriate because an enum value can only be one of its variants. Both version four and version six addresses are still fundamentally IP addresses, so they should be treated as the same type when the code is handling situations that apply to any kind of IP address.
We can express this concept in code by defining an IpAddrKind enumeration and
listing the possible kinds an IP address can be, V4 and V6. These are the
variants of the enum:
enum IpAddrKind {
V4,
V6,
}
IpAddrKind is now a custom data type that we can use elsewhere in our code.
Enum Values
We can create instances of each of the two variants of IpAddrKind like this:
let four = IpAddrKind.V4
let six = IpAddrKind.V6
Note that the variants of the enum are namespaced under its identifier using dot
notation: IpAddrKind.V4 and IpAddrKind.V6. This is useful because now both
values are of the same type: IpAddrKind. We can then, for instance, define a
function that takes any IpAddrKind:
fn route(ipKind: IpAddrKind) {
// handle routing
}
And we can call this function with either variant:
route(IpAddrKind.V4)
route(IpAddrKind.V6)
Using enums has even more advantages. Thinking more about our IP address type, at the moment we don't have a way to store the actual IP address data; we only know what kind it is. Given that you just learned about structs, you might be tempted to tackle this problem with structs:
enum IpAddrKind {
V4,
V6,
}
struct IpAddr {
kind: IpAddrKind,
address: String,
}
let home = IpAddr {
kind: IpAddrKind.V4,
address: "127.0.0.1".toString(),
}
let loopback = IpAddr {
kind: IpAddrKind.V6,
address: "::1".toString(),
}
Here, we've defined a struct IpAddr that has two fields: a kind field that is
of type IpAddrKind (the enum we defined previously) and an address field of
type String. We have two instances of this struct.
However, representing the same concept using just an enum is more concise: rather
than an enum inside a struct, we can put data directly into each enum variant.
This new definition of the IpAddr enum says that both V4 and V6 variants
will have associated String values:
enum IpAddr {
V4(String),
V6(String),
}
let home = IpAddr.V4("127.0.0.1".toString())
let loopback = IpAddr.V6("::1".toString())
We attach data to each variant of the enum directly, so there is no need for an
extra struct. Here, it's also easier to see another detail of how enums work: the
name of each enum variant that we define also becomes a function that constructs
an instance of the enum. That is, IpAddr.V4() is a function call that takes a
String argument and returns an instance of the IpAddr type.
There's another advantage to using an enum rather than a struct: each variant can
have different types and amounts of associated data. Version four IP addresses
will always have four numeric components that will have values between 0 and 255.
If we wanted to store V4 addresses as four UInt8 values but still express V6
addresses as one String value, we wouldn't be able to with a struct. Enums
handle this case with ease:
enum IpAddr {
V4(UInt8, UInt8, UInt8, UInt8),
V6(String),
}
let home = IpAddr.V4(127, 0, 0, 1)
let loopback = IpAddr.V6("::1".toString())
We've shown several different ways to define data structures to store version four
and version six IP addresses. However, as it turns out, wanting to store IP
addresses and encode which kind they are is so common that the standard library
has a definition we can use! Let's look at how the standard library defines
IpAddr: it has the exact enum and variants that we've defined and used, but it
embeds the address data inside the variants in the form of two different structs,
which are defined differently for each variant.
Enums with Named Fields
Enum variants can also have named fields, similar to structs:
#[derive(Debug, Clone)]
public enum Message {
Quit,
Move { x: Int, y: Int },
Write(String),
ChangeColor(Int, Int, Int),
}
This enum has four variants with different types:
Quithas no data associated with it at all.Movehas named fields, like a struct.Writeincludes a singleString.ChangeColorincludes threeIntvalues.
Defining an enum with variants such as the ones above is similar to defining
different kinds of struct definitions, except the enum doesn't use the struct
keyword and all the variants are grouped together under the Message type.
Creating instances of these variants:
let quit = Message.Quit
let moveMsg = Message.Move { x: 10, y: 20 }
let write = Message.Write("hello".toString())
let color = Message.ChangeColor(255, 128, 0)
Defining Methods on Enums
We're also able to define methods on enums using extension blocks. Here's a
method named call that we could define on our Message enum:
extension Message {
fn call() {
match self {
Message.Quit -> println!("Quit"),
Message.Move { x, y } -> println!("Move to (\(x), \(y))"),
Message.Write(text) -> println!("Write: \(text)"),
Message.ChangeColor(r, g, b) -> println!("Color: (\(r), \(g), \(b))"),
}
}
}
let m = Message.Write("hello".toString())
m.call()
The body of the method uses self to get the value that we called the method on.
In this example, we've created a variable m that has the value
Message.Write("hello".toString()), and that is what self will be in the body
of the call method when m.call() runs.
The Nullable Type: Using T? Instead of Option
Oxide provides a built-in way to express the concept of a value being present or
absent using nullable types. Instead of writing Option<T> as you would in Rust,
Oxide uses the more concise T? syntax. This is so common and useful that it's
built into the language itself.
The nullable type encodes the very common scenario in which a value could be something or it could be nothing. For example, if you request the first item of a non-empty list, you would get a value. If you request the first item of an empty list, you would get nothing.
Expressing this concept in terms of the type system means the compiler can check whether you've handled all the cases you should be handling; this functionality can prevent bugs that are extremely common in other programming languages.
Here's how you use nullable types in Oxide:
let someNumber: Int? = Some(5)
let someString: String? = Some("a string".toString())
let absentNumber: Int? = null
let absentString: String? = null
The type of someNumber is Int?. The type of someString is String?.
Because we've specified a type annotation, Oxide knows these are nullable types.
When we have a Some value, we know that a value is present and the value is
held within the Some. When we have a null value, in some sense it means the
same thing as null in other languages: we don't have a valid value.
So why is T? any better than having null? In short, because Int? and Int
are different types, the compiler won't let us use an Int? value as if it were
definitely an Int. For example, this code won't compile because it's trying
to add an Int? to an Int:
let x: Int = 5
let y: Int? = Some(5)
let sum = x + y // Error! Can't add Int and Int?
When we have a value of a type like Int in Oxide, the compiler will ensure we
always have a valid value. We can proceed confidently without having to check
for null before using that value. Only when we have a Int? do we have to
worry about possibly not having a value, and the compiler will make sure we
handle that case before using the value.
In other words, you have to convert a T? to a T before you can perform T
operations with it. Generally, this helps catch one of the most common issues
with null: assuming that something isn't null when it actually is.
Eliminating the risk of incorrectly assuming a not-null value helps you to be
more confident in your code. In order to have a value that can possibly be null,
you must explicitly opt in by making the type of that value T?. Then, when
you use that value, you are required to explicitly handle the case when the
value is null. Everywhere that a value has a type that isn't a T?, you can
safely assume that the value isn't null.
So how do you get the T value out of a Some variant when you have a value of
type T? so that you can use that value? The T? type has a large number of
methods that are useful in a variety of situations; you can find them in the
Rust documentation for Option<T>. Becoming familiar with the methods on
Option<T> will be extremely useful in your journey with Oxide.
In general, in order to use a T? value, you want to have code that will handle
each variant. You want some code that will run only when you have a Some(T)
value, and this code is allowed to use the inner T. You want some other code
to run only if you have a null value, and that code doesn't have a T value
available. The match expression is a control flow construct that does just
this when used with enums: it will run different code depending on which variant
of the enum it has, and that code can use the data inside the matching variant.
The match Expression
Oxide has an extremely powerful control flow construct called match that allows
you to compare a value against a series of patterns and then execute code based
on which pattern matches. Patterns can be made up of literal values, variable
names, wildcards, and many other things. The power of match comes from the
expressiveness of the patterns and the fact that the compiler confirms that all
possible cases are handled.
Think of a match expression as being like a coin-sorting machine: coins slide
down a track with variously sized holes along it, and each coin falls through
the first hole it encounters that it fits into. In the same way, values go
through each pattern in a match, and at the first pattern the value "fits,"
the value falls into the associated code block to be used during execution.
Speaking of coins, let's use them as an example using match! We can write a
function that takes an unknown US coin and, in a similar way as the counting
machine, determines which coin it is and returns its value in cents:
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn valueInCents(coin: Coin): Int {
match coin {
Coin.Penny -> 1,
Coin.Nickel -> 5,
Coin.Dime -> 10,
Coin.Quarter -> 25,
}
}
Let's break down the match in the valueInCents function. First we list the
match keyword followed by an expression, which in this case is the value coin.
This seems very similar to a conditional expression used with if, but there's
a big difference: with if, the condition needs to evaluate to a Boolean value,
but here it can be any type. The type of coin in this example is the Coin
enum that we defined.
Next are the match arms. An arm has two parts: a pattern and some code. The
first arm here has a pattern that is the value Coin.Penny and then the -> that
separates the pattern and the code to run. The code in this case is just the
value 1. Each arm is separated from the next with a comma.
When the match expression executes, it compares the resultant value against the
pattern of each arm, in order. If a pattern matches the value, the code associated
with that pattern is executed. If that pattern doesn't match the value, execution
continues to the next arm, much as in a coin-sorting machine.
The code associated with each arm is an expression, and the resultant value of
the expression in the matching arm is the value that gets returned for the entire
match expression.
We don't typically use curly brackets if the match arm code is short, as it is in the previous example where each arm just returns a value. If you want to run multiple lines of code in a match arm, you must use curly brackets, and the comma following the arm is then optional:
fn valueInCents(coin: Coin): Int {
match coin {
Coin.Penny -> {
println!("Lucky penny!")
1
},
Coin.Nickel -> 5,
Coin.Dime -> 10,
Coin.Quarter -> 25,
}
}
Patterns That Bind to Values
Another useful feature of match arms is that they can bind to the parts of the values that match the pattern. This is how we can extract values out of enum variants.
As an example, let's change one of our enum variants to hold data inside it.
From 1999 through 2008, the United States minted quarters with different designs
for each of the 50 states on one side. No other coins got state designs, so only
quarters have this extra value. We can add this information to our enum by
changing the Quarter variant to include a UsState value stored inside it:
#[derive(Debug, Clone, Copy)]
enum UsState {
Alabama,
Alaska,
Arizona,
Arkansas,
California,
// ... etc
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
Let's imagine that a friend is trying to collect all 50 state quarters. While we sort our loose change by coin type, we'll also call out the name of the state associated with each quarter so that if it's one our friend doesn't have, they can add it to their collection.
In the match expression for this code, we add a variable called state to the
pattern that matches values of the variant Coin.Quarter. When a Coin.Quarter
matches, the state variable will bind to the value of that quarter's state.
Then we can use state in the code for that arm:
fn valueInCents(coin: Coin): Int {
match coin {
Coin.Penny -> 1,
Coin.Nickel -> 5,
Coin.Dime -> 10,
Coin.Quarter(state) -> {
println!("State quarter from \(state:?)")
25
},
}
}
If we were to call valueInCents(Coin.Quarter(UsState.Alaska)), coin would be
Coin.Quarter(UsState.Alaska). When we compare that value with each of the match
arms, none of them match until we reach Coin.Quarter(state). At that point, the
binding for state will be the value UsState.Alaska. We can then use that
binding in the println! expression, thus getting the inner state value out of
the Coin enum variant for Quarter.
Matching with Nullable Types
In the previous section, we wanted to get the inner T value out of the Some
case when using T?; we can also handle T? using match, as we did with the
Coin enum! Instead of comparing coins, we'll compare the variants of T?,
but the way the match expression works remains the same.
Let's say we want to write a function that takes a Int? and, if there's a
value inside, adds 1 to that value. If there isn't a value inside, the function
should return the null value and not attempt to perform any operations.
This function is very easy to write, thanks to match:
fn plusOne(x: Int?): Int? {
match x {
null -> null,
Some(i) -> Some(i + 1),
}
}
let five: Int? = Some(5)
let six = plusOne(five)
let none = plusOne(null)
Let's examine the first execution of plusOne in more detail. When we call
plusOne(five), the variable x in the body of plusOne will have the value
Some(5). We then compare that against each match arm:
null -> null,
The Some(5) value doesn't match the pattern null, so we continue to the next
arm:
Some(i) -> Some(i + 1),
Does Some(5) match Some(i)? It does! We have the same variant. The i binds
to the value contained in Some, so i takes the value 5. The code in the
match arm is then executed, so we add 1 to the value of i and create a new
Some value with our total 6 inside.
Now let's consider the second call of plusOne, where x is null. We enter
the match and compare to the first arm:
null -> null,
It matches! There's no value to add to, so the program stops and returns the
null value on the right side of ->. Because the first arm matched, no other
arms are compared.
Combining match and enums is useful in many situations. You'll see this pattern
a lot in Oxide code: match against an enum, bind a variable to the data inside,
and then execute code based on it. It's a bit tricky at first, but once you get
used to it, you'll wish you had it in all languages. It's consistently a user
favorite.
Matches Are Exhaustive
There's one other aspect of match we need to discuss: the arms' patterns must
cover all possibilities. Consider this version of our plusOne function, which
has a bug and won't compile:
fn plusOne(x: Int?): Int? {
match x {
Some(i) -> Some(i + 1),
}
}
We didn't handle the null case, so this code will cause a bug. Luckily, it's
a bug the compiler knows how to catch. If we try to compile this code, we'll get
an error indicating that we haven't handled all possible cases.
Matches in Oxide are exhaustive: we must exhaust every last possibility in
order for the code to be valid. Especially in the case of T?, when the
compiler ensures we explicitly handle the null case, it protects us from
assuming we have a value when we might have null, thus making the mistake
discussed earlier impossible.
Catch-all Patterns with else
Using enums, we can also take special actions for a few particular values, but for all other values take one default action. Let's look at an example where we want to implement game logic for a dice roll:
fn handleDiceRoll(diceRoll: Int) {
match diceRoll {
3 -> addFancyHat(),
7 -> removeFancyHat(),
else -> reroll(),
}
}
fn addFancyHat() {}
fn removeFancyHat() {}
fn reroll() {}
For the first two arms, the patterns are the literal values 3 and 7. For the
last arm that covers every other possible value, the pattern is the keyword
else. This is Oxide's catch-all pattern (equivalent to _ in Rust). The code
that runs for the else arm calls the reroll function.
This code compiles, even though we haven't listed all the possible values an Int
can have, because the else pattern will match all values not specifically listed.
The catch-all pattern meets the requirement that match must be exhaustive. Note
that we have to put the catch-all arm last because the patterns are evaluated in
order. If we put the catch-all arm earlier, the other arms would never run.
Catch-all with a Bound Variable
Sometimes you want to use the matched value in your catch-all arm. You can bind
the value to a variable by using a name other than else:
fn handleDiceRoll(diceRoll: Int) {
match diceRoll {
3 -> addFancyHat(),
7 -> removeFancyHat(),
other -> movePlayer(other),
}
}
fn movePlayer(spaces: Int) {
println!("Moving \(spaces) spaces")
}
Here, we're using the variable other to capture all values that don't match
3 or 7, and we use that value in the arm's code.
Ignoring Values with else
When you want a catch-all but don't need the value, use else:
fn handleDiceRoll(diceRoll: Int) {
match diceRoll {
3 -> addFancyHat(),
7 -> removeFancyHat(),
else -> {}, // Do nothing
}
}
Here, we're telling the compiler explicitly that we aren't going to use any other
value, by using else with an empty block.
Matching Multiple Patterns
You can match multiple patterns in a single arm using the | operator:
fn describeLetter(letter: char): String {
match letter {
'a' | 'e' | 'i' | 'o' | 'u' -> "vowel".toString(),
'a'..='z' -> "consonant".toString(),
else -> "not a lowercase letter".toString(),
}
}
Matching with Guards
Sometimes pattern matching alone isn't expressive enough. Match guards allow you to add an additional condition to a pattern:
fn checkNumber(x: Int?) {
match x {
Some(n) if n < 0 -> println!("Negative: \(n)"),
Some(n) if n > 0 -> println!("Positive: \(n)"),
Some(n) -> println!("Zero"),
null -> println!("No value"),
}
}
The if n < 0 part is called a match guard. It's an additional condition on a
match arm that must also be true for that arm to be chosen. Match guards are
useful for expressing more complex ideas than a pattern alone allows.
Destructuring Enums with Named Fields
When matching enums with named fields, you can destructure them using struct-like syntax:
enum Message {
Quit,
Move { x: Int, y: Int },
Write(String),
ChangeColor(Int, Int, Int),
}
fn processMessage(msg: Message) {
match msg {
Message.Quit -> {
println!("Quit received")
},
Message.Move { x, y } -> {
println!("Moving to x=\(x), y=\(y)")
},
Message.Write(text) -> {
println!("Text message: \(text)")
},
Message.ChangeColor(r, g, b) -> {
println!("Changing color to RGB(\(r), \(g), \(b))")
},
}
}
You can also rename the bound variables:
match msg {
Message.Move { x: horizontal, y: vertical } -> {
println!("Moving horizontally: \(horizontal), vertically: \(vertical)")
},
else -> {},
}
Nested Patterns
Patterns can be nested to match complex data structures:
enum Color {
Rgb(Int, Int, Int),
Hsv(Int, Int, Int),
}
enum Message {
Quit,
ChangeColor(Color),
}
fn processMessage(msg: Message) {
match msg {
Message.ChangeColor(Color.Rgb(r, g, b)) -> {
println!("RGB: \(r), \(g), \(b)")
},
Message.ChangeColor(Color.Hsv(h, s, v)) -> {
println!("HSV: \(h), \(s), \(v)")
},
Message.Quit -> println!("Quit"),
}
}
The match expression is one of Oxide's most powerful features. It's used
extensively throughout Oxide code for control flow, error handling, and working
with optional values. As you become more familiar with pattern matching, you'll
find yourself reaching for match whenever you need to handle multiple cases
based on the structure of your data.
if let and while let
The if let syntax lets you combine if and let into a less verbose way to
handle values that match one pattern while ignoring the rest. Consider the
following program that matches on a Int? value in the config_max variable
but only wants to execute code if the value is a Some variant:
let configMax: Int? = Some(3)
match configMax {
Some(max) -> println!("The maximum is configured to be \(max)"),
null -> {},
}
If the value is Some, we print out the value in the Some variant by binding
the value to the variable max in the pattern. We don't want to do anything
with the null value. To satisfy the match expression, we have to add
null -> {} after processing just one variant, which is annoying boilerplate
code to add.
Instead, we could write this in a shorter way using if let. The following code
behaves the same as the match above:
let configMax: Int? = Some(3)
if let Some(max) = configMax {
println!("The maximum is configured to be \(max)")
}
The syntax if let takes a pattern and an expression separated by an equal sign.
It works the same way as a match, where the expression is given to the match
and the pattern is its first arm. In this case, the pattern is Some(max), and
the max binds to the value inside the Some. We can then use max in the body
of the if let block in the same way we used max in the corresponding match
arm. The code in the if let block isn't run if the value doesn't match the
pattern.
Using if let means less typing, less indentation, and less boilerplate code.
However, you lose the exhaustive checking that match enforces. Choosing between
match and if let depends on what you're doing in your particular situation
and whether gaining conciseness is an appropriate trade-off for losing exhaustive
checking.
In other words, you can think of if let as syntax sugar for a match that runs
code when the value matches one pattern and then ignores all other values.
Auto-Unwrapping for Nullable Types
Oxide provides a powerful feature for working with nullable types: automatic
unwrapping in if let expressions. When the right-hand side of an if let is
a nullable type (T?), you don't need to explicitly write Some(...) in your
pattern. Oxide will automatically unwrap the value for you.
Consider this code:
let maybeUser: User? = findUser(id)
// Traditional way with explicit Some
if let Some(user) = maybeUser {
println!("Hello, \(user.name)")
}
// Oxide auto-unwrap - simpler and more readable
if let user = maybeUser {
println!("Hello, \(user.name)")
}
Both forms are equivalent and compile to the same code. The auto-unwrap syntax
(if let user = maybeUser) is more concise and reads naturally: "if there's a
user, use it."
This is particularly useful when working with function return values:
fn findUserById(id: Int): User? {
// ... lookup logic
null
}
fn processUser(id: Int) {
if let user = findUserById(id) {
println!("Found user: \(user.name)")
sendWelcomeEmail(user)
}
}
The auto-unwrap also works with chained method calls:
if let email = user.profile?.email {
sendNotification(email)
}
Using else with if let
We can include an else with an if let. The block of code that goes with the
else is the same as the block of code that would go with the null case in
the match expression:
let coin = Coin.Quarter(UsState.Alaska)
if let Coin.Quarter(state) = coin {
println!("State quarter from \(state:?)")
} else {
println!("Not a quarter")
}
This is equivalent to:
match coin {
Coin.Quarter(state) -> println!("State quarter from \(state:?)"),
else -> println!("Not a quarter"),
}
Combining if let with else if
You can chain if let expressions with else if and else if let:
fn describeValue(value: Int?) {
if let n = value {
if n > 100 {
println!("Large number: \(n)")
} else if n > 0 {
println!("Positive number: \(n)")
} else if n < 0 {
println!("Negative number: \(n)")
} else {
println!("Zero")
}
} else {
println!("No value")
}
}
Or matching against multiple nullable types:
fn processCoordinates(x: Int?, y: Int?) {
if let xVal = x {
if let yVal = y {
println!("Point: (\(xVal), \(yVal))")
} else {
println!("Only X coordinate: \(xVal)")
}
} else if let yVal = y {
println!("Only Y coordinate: \(yVal)")
} else {
println!("No coordinates")
}
}
if let with Conditions
You can combine if let with additional conditions using &&:
let user: User? = findUser(id)
if let user = user && user.isActive {
greet(user)
}
This is equivalent to:
if let Some(user) = user {
if user.isActive {
greet(user)
}
}
The combined syntax is more concise and clearly expresses the intent: "if we have a user AND they are active, greet them."
while let
Similar to if let, Oxide provides while let for looping as long as a pattern
continues to match. This is particularly useful when working with iterators or
any sequence that returns nullable values.
var stack: Vec<Int> = vec![1, 2, 3]
while let Some(top) = stack.pop() {
println!("Popped: \(top)")
}
This code pops values from the stack and prints them until the stack is empty.
The pop method returns Int?, returning Some(value) when there's a value
and null when the stack is empty.
With auto-unwrap syntax:
var stack: Vec<Int> = vec![1, 2, 3]
while let top = stack.pop() {
println!("Popped: \(top)")
}
Practical Examples
Working with Configuration
struct Config {
databaseUrl: String?,
maxConnections: Int?,
timeout: Int?,
}
fn loadConfig(): Config {
Config {
databaseUrl: Some("postgres://localhost/db".toString()),
maxConnections: Some(10),
timeout: null,
}
}
fn initializeDatabase() {
let config = loadConfig()
if let url = config.databaseUrl {
println!("Connecting to: \(url)")
let connections = config.maxConnections ?? 5
println!("Max connections: \(connections)")
if let timeout = config.timeout {
println!("Timeout: \(timeout)s")
} else {
println!("No timeout configured, using default")
}
} else {
println!("No database URL configured!")
}
}
Processing Results from a Search
struct SearchResult {
title: String,
url: String,
snippet: String?,
}
fn search(query: str): SearchResult? {
// ... search logic
Some(SearchResult {
title: "Example".toString(),
url: "https://example.com".toString(),
snippet: Some("An example result".toString()),
})
}
fn displaySearchResult(query: str) {
if let result = search(query) {
println!("Found: \(result.title)")
println!("URL: \(result.url)")
if let snippet = result.snippet {
println!("Snippet: \(snippet)")
}
} else {
println!("No results found for '\(query)'")
}
}
Iterating with while let
struct Node {
value: Int,
next: Box<Node>?,
}
fn sumLinkedList(head: Box<Node>?): Int {
var sum = 0
var current = head
while let node = current {
sum += node.value
current = node.next.clone()
}
sum
}
Handling User Input
fn readValidNumber(): Int? {
// Simulating user input
Some(42)
}
fn processInput() {
while let number = readValidNumber() {
if number == 0 {
println!("Exiting...")
break
}
println!("Processing: \(number)")
}
}
When to Use if let vs match
Use if let when:
- You only care about one specific pattern
- You want concise code for simple cases
- The "else" case is trivial or can be ignored
Use match when:
- You need to handle multiple patterns explicitly
- You want the compiler to ensure you've handled all cases
- The logic for different patterns is complex
// Good use of if let - only care about Some case
if let user = findUser(id) {
greet(user)
}
// Good use of match - need to handle all cases explicitly
match command {
Command.Start -> startServer(),
Command.Stop -> stopServer(),
Command.Restart -> {
stopServer()
startServer()
},
Command.Status -> printStatus(),
}
The if let and while let constructs provide a more ergonomic way to work
with nullable types and pattern matching when you don't need the full power of
match. Combined with Oxide's auto-unwrap feature for nullable types, they make
working with optional values concise and readable.
Appendix A: Keywords
The Oxide programming language uses a combination of Oxide-specific keywords and keywords shared with Rust. This appendix lists all keywords and provides information about their usage.
Oxide-Specific Keywords
These keywords are unique to Oxide or have different semantics from their Rust counterparts:
| Keyword | Category | Rust Equivalent | Description |
|---|---|---|---|
var | Binding | let mut | Mutable variable binding. Declares a variable that can be reassigned. |
public | Visibility | pub | Public visibility modifier. Makes items accessible from outside the current module. |
import | Module | use | Module imports. Brings items from other modules into scope. |
external | Module | (with module) mod X; | External module declaration. Declares a module whose body is in a separate file. |
module | Module | mod | Module definition. Defines a module either inline or as an external reference. |
extension | Implementation | impl | Type extension/implementation. Adds methods to a type or implements a trait for a type. |
guard | Control Flow | (no direct equivalent) | Early return guard. Ensures a condition holds or executes a diverging else block. |
mutating | Method Modifier | &mut self | Mutable method modifier. Indicates the method borrows self mutably. |
consuming | Method Modifier | self | Consuming method modifier. Indicates the method takes ownership of self. |
static | Method Modifier | (no self) | Static method modifier. Indicates the method has no self parameter. |
null | Literal | None | Null literal. Represents the absence of a value in nullable types (T?). |
Detailed Usage
var - Mutable Variable Binding
var counter = 0
counter += 1 // Allowed because counter is mutable
var items: Vec<String> = vec![]
items.push("hello")
See Chapter 3.1: Variables and Mutability for more details.
public - Public Visibility
public fn createUser(name: str): User {
User { name: name.toString() }
}
public struct Config {
path: PathBuf,
verbose: Bool,
}
import - Module Imports
import std.collections.HashMap
import std.fs
import anyhow.{ Result, Error }
import crate.engine.{ Action, scan }
external module - External Module Declaration
// Declares a module in a separate file (engine.ox or engine/mod.ox)
external module engine
external module config
module - Inline Module Definition
module tests {
import super.*
#[test]
fn testSomething() {
assert!(true)
}
}
extension - Type Extension
extension Config {
public fn validate(): Bool {
self.path.exists()
}
}
// Trait implementation
import std.fmt.{ Display, Formatter, Result }
extension Config: Display {
fn fmt(f: &mut Formatter): Result {
write!(f, "Config: \(self.path)")
}
}
See Chapter 5.3: Method Syntax for more details.
guard - Early Return Guard
guard condition else {
return // Must diverge!
}
guard let user = findUser(id) else {
return Err(anyhow!("User not found"))
}
// user is now available
See Chapter 3.5: Control Flow for more details.
mutating, consuming, static - Method Modifiers
extension Config {
// Default: &self (immutable borrow)
fn validate(): Bool { self.path.exists() }
// mutating: &mut self
mutating fn setPath(path: PathBuf) { self.path = path }
// consuming: self (takes ownership)
consuming fn destroy() { drop(self) }
// static: no self parameter
static fn load(): Config? { Self.fromFile("config.toml") }
}
null - Null Literal
let name: String? = null
let value: Int? = null
match optional {
Some(x) -> process(x),
null -> handleNull(),
}
See Chapter 6.3: Concise Control Flow with if let for more details.
Shared Keywords
These keywords are shared with Rust and have the same or very similar semantics:
| Keyword | Category | Description |
|---|---|---|
let | Binding | Immutable variable binding |
fn | Function | Function definition |
struct | Type | Structure type definition |
enum | Type | Enumeration type definition |
trait | Type | Trait definition |
type | Type | Type alias definition |
const | Binding | Compile-time constant |
async | Async | Asynchronous function modifier |
await | Async | Await a future (prefix in Oxide, postfix in Rust) |
if | Control Flow | Conditional expression |
else | Control Flow | Alternative branch / match wildcard |
match | Control Flow | Pattern matching expression |
for | Control Flow | For loop |
while | Control Flow | While loop |
loop | Control Flow | Infinite loop |
break | Control Flow | Break out of a loop |
continue | Control Flow | Continue to next loop iteration |
return | Control Flow | Return from a function |
self | Reference | Reference to the current instance |
Self | Type | Type alias for the implementing type |
super | Module | Parent module reference |
crate | Module | Crate root reference |
where | Generic | Generic type constraints |
as | Conversion | Type casting |
in | Control Flow | Used in for loops |
unsafe | Safety | Unsafe code block or function |
dyn | Type | Dynamic trait object |
move | Closure | Move semantics for closures |
ref | Pattern | Reference pattern binding |
mut | Modifier | Mutable reference or pattern binding |
true | Literal | Boolean true |
false | Literal | Boolean false |
impl | Implementation | Rust's implementation keyword (use extension in Oxide) |
pub | Visibility | Rust's public visibility (use public in Oxide) |
use | Module | Rust's import keyword (use import in Oxide) |
mod | Module | Rust's module keyword (use module in Oxide) |
extern | FFI | External function declaration |
Special Note: await
While await is shared with Rust, its position differs:
// Oxide: prefix await
let response = await client.get(url).send()?
let data = await response.json()?
#![allow(unused)] fn main() { // Rust: postfix .await let response = client.get(url).send().await?; let data = response.json().await?; }
Oxide uses prefix await because it reads more naturally from left to right and matches the syntax of Swift, Kotlin, JavaScript, and Python.
Special Note: else in Match
In Oxide, else serves double duty as both the alternative branch in if expressions and as the wildcard pattern in match expressions:
match command {
Command.Run -> executeRun(),
Command.Build -> executeBuild(),
else -> showHelp(), // Wildcard - equivalent to Rust's _
}
Reserved Keywords
The following keywords are reserved for potential future use:
| Keyword | Notes |
|---|---|
abstract | Reserved |
become | Reserved |
box | Reserved |
do | Reserved |
final | Reserved |
macro | Reserved |
override | Reserved |
priv | Reserved |
try | Reserved |
typeof | Reserved |
unsized | Reserved |
virtual | Reserved |
yield | Reserved |
These keywords cannot be used as identifiers even though they do not currently have a defined meaning in Oxide.
Raw Identifiers
If you need to use a keyword as an identifier (for example, when interfacing with Rust code that uses reserved keywords as names), you can use the raw identifier syntax:
let r#type = "keyword" // Uses 'type' as an identifier
Quick Reference
Keyword Mapping: Oxide to Rust
| Oxide | Rust |
|---|---|
var x = ... | let mut x = ... |
public fn | pub fn |
import a.b | use a::b; |
Type.method() | Type::method() |
external module x | mod x; |
module x { } | mod x { } |
extension T { } | impl T { } |
extension T: Trait { } | impl Trait for T { } |
guard c else { } | if !c { } or let-else |
mutating fn | fn(&mut self) |
consuming fn | fn(self) |
static fn | fn() (no self) |
null | None |
await expr | expr.await |
match { else -> } | match { _ => } |
IMPORTANT: The Rust syntax in the right column is NOT valid in Oxide. These are grammar changes, not style preferences. For example,
::does not exist in Oxide - using it will cause a syntax error. Oxide uses.as its only path separator.
Categories at a Glance
Bindings: let, var, const
Functions: fn, async, return
Types: struct, enum, trait, type, Self
Methods: extension, mutating, consuming, static, self
Control Flow: if, else, match, for, while, loop, break, continue, guard
Modules: import, module, external, public, super, crate
Async: async, await
Safety: unsafe
Literals: true, false, null
Modifiers: mut, ref, move, dyn, where, as, in
Appendix B: Operators and Symbols
This appendix provides a quick reference for all operators in Oxide, including their precedence and Oxide-specific operators.
Operator Precedence Table
Operators are listed from highest precedence (evaluated first) to lowest (evaluated last).
| Precedence | Operators | Description | Associativity |
|---|---|---|---|
| 1 (highest) | . | Path/field access | Left |
| 2 | () [] ? !! | Call, index, try, force unwrap | Left |
| 3 | await ! - * & &mut | Prefix operators | Right |
| 4 | as | Type cast | Left |
| 5 | * / % | Multiplication, division, remainder | Left |
| 6 | + - | Addition, subtraction | Left |
| 7 | << >> | Bit shifts | Left |
| 8 | & | Bitwise AND | Left |
| 9 | ^ | Bitwise XOR | Left |
| 10 | | | Bitwise OR | Left |
| 11 | == != < > <= >= | Comparisons | Requires parentheses |
| 12 | && | Logical AND | Left |
| 13 | || | Logical OR | Left |
| 14 | ?? | Null coalescing | Left |
| 15 | .. ..= | Range operators | Requires parentheses |
| 16 | = += -= *= /= %= &= |= ^= <<= >>= | Assignment | Right |
| 17 (lowest) | return break continue | Control flow | Right |
Operators by Category
Arithmetic Operators
| Operator | Description | Example |
|---|---|---|
+ | Addition | 5 + 3 |
- | Subtraction | 10 - 4 |
* | Multiplication | 6 * 7 |
/ | Division | 20 / 4 |
% | Remainder | 17 % 5 |
- | Negation (unary) | -42 |
Comparison Operators
| Operator | Description | Example |
|---|---|---|
== | Equal to | x == 5 |
!= | Not equal to | x != y |
< | Less than | x < y |
> | Greater than | x > y |
<= | Less than or equal | x <= 5 |
>= | Greater than or equal | x >= 10 |
Logical Operators
| Operator | Description | Example |
|---|---|---|
&& | Logical AND (short-circuit) | a && b |
|| | Logical OR (short-circuit) | a || b |
! | Logical NOT | !a |
Bitwise Operators
| Operator | Description | Example |
|---|---|---|
& | Bitwise AND | a & b |
| | Bitwise OR | a | b |
^ | Bitwise XOR | a ^ b |
<< | Left shift | a << 2 |
>> | Right shift | a >> 2 |
Reference Operators
| Operator | Description | Example |
|---|---|---|
& | Create shared reference | &value |
&mut | Create mutable reference | &mut value |
* | Dereference | *ptr |
Assignment Operators
| Operator | Equivalent |
|---|---|
= | Assign |
+= | x = x + y |
-= | x = x - y |
*= | x = x * y |
/= | x = x / y |
%= | x = x % y |
&= |= ^= <<= >>= | Bitwise compound assignment |
Range Operators
| Operator | Description | Example |
|---|---|---|
.. | Exclusive range | 0..5 (0 to 4) |
..= | Inclusive range | 1..=5 (1 to 5) |
Oxide-Specific Operators
Null Coalescing (??)
Provides a default value when the left-hand side is null.
let name = userName ?? "Anonymous"
let config = Config.load() ?? Config.default()
CRITICAL:
??works withOption<T>ONLY, NOTResult<T, E>// This will NOT compile: let value: Result<Int, Error> = Err(someError) let result = value ?? 0 // ERROR: ?? only works with Option<T>Why?
Result<T, E>contains typed error information that should not be silently discarded. Using??would hide important error details.For Result, use explicit methods:
let value = riskyOperation().unwrapOr(default) let value = riskyOperation().unwrapOrElse { err -> handleError(err) }
Rust equivalent: .unwrap_or() or .unwrap_or_else()
Force Unwrap (!!)
Forcefully unwraps an optional value. Panics if null.
let user = findUser(id)!! // Panics if null
Rust equivalent: .unwrap()
Warning: Use sparingly. Prefer
if let, pattern matching, or??when possible.
Prefix Await
Oxide uses prefix await (unlike Rust's postfix .await).
let data = await fetchData()
let response = await client.get(url).send()?
Precedence: await binds tighter than ?, so await expr? means (await expr)?
Rust equivalent: expr.await
Try Operator (?)
Unchanged from Rust. Propagates errors in Result or Option returning functions.
fn readConfig(): Result<Config, Error> {
let content = std.fs.readToString("config.toml")?
Ok(parseConfig(content)?)
}
Symbols and Delimiters
| Symbol | Usage |
|---|---|
{ } | Blocks, closures, struct/enum bodies |
( ) | Grouping, function parameters, tuples |
[ ] | Array literals, indexing |
< > | Generic type parameters |
#[ ] | Attributes |
. | Field access, method call, path separator |
: | Type annotation, return type |
-> | Closure params, match arms |
, | List separator |
; | Statement terminator (optional) |
Summary: Differences from Rust
| Oxide | Rust | Description |
|---|---|---|
?? | .unwrap_or() | Null coalescing (Option only) |
!! | .unwrap() | Force unwrap |
await expr | expr.await | Prefix await |
. | :: | Path separator |
: | -> | Return type annotation |
-> | => | Match arm, closure params |
IMPORTANT: The Rust operators in the right column (
::,->for return types,=>for match arms) are not valid Oxide syntax. These are grammar changes, not style preferences. Using::in Oxide code will result in a syntax error. Oxide uses.as its only path separator.