Basic Syntax: Variables, Types, and Pattern Matching

🔊

In the previous post, we discussed why Rust is worth learning and how to run your first Hello World. This post dives directly into syntax—variables, types, control flow, functions, and pattern matching. The core goal is simple: enable you to read and write Rust code.

If you have a Go background, none of these concepts will be unfamiliar. I’ll provide comparisons at key points to help map your existing knowledge to Rust.

Variable Declaration

Rust’s variable declaration is straightforward, with a core principle: immutable by default, add mut when mutability is needed:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fn main() {
    // Immutable variable (default)
    let x = 5;
    // x = 6;  // Compile error! cannot assign twice to immutable variable

    // Mutable variable
    let mut y = 5;
    y = 6;  // OK

    // Compile-time constant
    const PI: f64 = 3.14159;

    // Variable shadowing
    let x = x + 1;  // Create new binding, shadows old value
    let x = "hello";  // Can even change type
}

Go/Rust Comparison:

GoRust
Immutable bindingconst x = 5 (package-level) or x := 5 (function-level)let x = 5
Mutable bindingx := 5let mut x = 5
Variable shadowingx := 5; x := 10 (syntax error)let x = 5; let x = 10 (OK)
Compile-time constantconst PI = 3.14const PI: f64 = 3.14

Go doesn’t have truly immutable bindings—const only applies to package-level constants, and := declarations in functions are mutable by default. Rust defaults to immutable, requiring explicit mut annotation for modification.

Variable shadowing is a unique Rust feature. Go doesn’t allow shadowing with the same variable name, but Rust allows creating new bindings with the same name via new let, even changing types. This is practical in data transformation scenarios—parse a string to a number, then perform calculations.

const in both Go and Rust are compile-time constants, but Rust’s const requires explicit type annotation.

Basic Data Types

Rust’s type system retains traditional classifications while introducing stricter type constraints:

Type CategoryRust TypesDescription
Signed Integersi8, i16, i32, i64, i128, isizeisize = platform-dependent signed integer
Unsigned Integersu8, u16, u32, u64, u128, usizeusize = platform-dependent unsigned integer
Floating Pointf32, f64IEEE 754 standard
Booleanbooltrue / false
CharactercharUnicode scalar value (4 bytes)
String&str, StringString slice / heap-allocated string
Tuple(T1, T2, T3)Fixed-length heterogeneous collection
Array[T; N]Fixed-length homogeneous collection

Go developers should note the difference in character types. Rust’s char is a Unicode scalar value (4 bytes), representing any Unicode character, while Go’s rune is also a 4-byte Unicode code point. However, Go’s byte is an alias for uint8 (1 byte), while Rust doesn’t have a separate byte type—use u8 directly.

isize and usize are platform-dependent integer types corresponding to pointer size—32-bit on 32-bit systems, 64-bit on 64-bit systems. This is similar to Go’s int/uint.

String types come in two flavors: &str is a string slice (immutable, borrowed), and String is a heap-allocated string (mutable, owned). This differs from Go’s design of string (immutable) + []byte (mutable). Rust ensures memory safety through borrowing rules, without distinguishing between UTF-8 byte sequences and string types.

Tuple types use parentheses, like (i32, f64, bool). Go has no built-in tuple type but can simulate via structs or multiple return values.

rust
1
2
3
4
5
6
7
8
// Tuple example
let tuple: (i32, f64, bool) = (1, 3.14, true);
let (x, y, z) = tuple;  // Destructuring
let first = tuple.0;     // Access by index

// Array example
let arr: [i32; 5] = [1, 2, 3, 4, 5];
let first = arr[0];

Control Flow

if Expression

Rust’s if is an expression—it can return values:

rust
1
2
3
fn max(a: i32, b: i32) -> i32 {
    if a > b { a } else { b }
}

Go’s if is a statement and cannot return values; Rust’s if is an expression. Note that if expressions must return values of the same type.

rust
1
2
3
4
5
6
// Condition must be bool type
let number = 6;
// if number {  // Compile error! expected `bool`, found integer
if number != 0 {
    println!("number is non-zero");
}

Rust’s condition must be of bool type—unlike C/Go, non-zero values are not automatically treated as true.

match Expression

match is Rust’s killer feature, much more powerful than Go’s switch:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

// Pattern matching
fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        None => None,
        Some(i) => Some(i + 1),
    }
}

// Range matching
fn classify(n: i32) -> &'static str {
    match n {
        0 => "Zero",
        1..=9 => "Single digit",
        10..=99 => "Two digits",
        _ => "Other",
    }
}

Go/Rust Comparison:

GoRust
FallthroughFallthrough by default, need break to preventNo fallthrough, each branch is independent
ExhaustivenessNot required, has defaultRequired, compiler checks
Pattern matchingLimited (type switch)Powerful (structs, ranges, Option, etc.)
ExpressionNot an expressionIs an expression, can return values

Rust’s match must exhaust all possibilities, otherwise compilation fails. This guarantees no case is missed, avoiding the common “forgot default” bug in Go.

Each branch in match uses => to connect pattern and expression. _ is a wildcard matching all unlisted cases. Range matching uses ..= for inclusive ranges, more intuitive than Go’s case.

Option<T> is Rust’s standard library type representing “value or no value”, lighter than Go’s (T, error). Some(T) indicates a value, None indicates no value. Through match, you can elegantly handle both cases.

Loops

Rust has three loop types: loop, while, for:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
fn main() {
    // loop — infinite loop, must break out
    let mut count = 0;
    let result = loop {
        count += 1;
        if count == 10 {
            break count * 2;  // break can return values
        }
    };

    // while — conditional loop
    let mut i = 0;
    while i < 10 {
        println!("{}", i);
        i += 1;
    }

    // for — iterator loop
    let a = [10, 20, 30, 40, 50];
    for element in a {
        println!("the value is: {}", element);
    }

    // for range
    for number in 1..4 {  // 1, 2, 3 (excluding 4)
        println!("{}", number);
    }

    for number in 1..=4 {  // 1, 2, 3, 4 (including 4)
        println!("{}", number);
    }
}

Go/Rust Comparison:

ScenarioGoRust
Infinite loopfor { ... }loop { ... }
Conditional loopfor condition { ... }while condition { ... }
Iterate collectionfor i, v := range itemsfor (i, v) in items.iter().enumerate()
Index loopfor i := 0; i < n; i++for i in 0..n

Rust’s for loop is based on iterators, more general than Go’s range. Through the iterator pattern, you can iterate over any type implementing the IntoIterator trait.

loop is a Rust feature—infinite loops must have explicit break, avoiding the common “forgot break” infinite loop bug in Go. break can even return values, practical in certain scenarios.

Functions

Rust uses the fn keyword to define functions. Parameters must have type annotations, and return type is specified after ->:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Basic function
fn add(a: i32, b: i32) -> i32 {
    a + b  // Last expression (no semicolon) is return value
}

// Multiple return values (via tuple)
fn divide(a: f64, b: f64) -> (f64, f64) {
    (a / b, a % b)
}

fn main() {
    let result = add(1, 2);
    let (quotient, remainder) = divide(10.0, 3.0);
}

Go/Rust Comparison:

FeatureGoRust
Function signaturefunc add(a, b int) intfn add(a: i32, b: i32) -> i32
Multiple return valuesNative supportVia tuple
Named return valuesSupportedNot supported
Omit return valueCan omit (with return type)Last expression automatically returned

Rust doesn’t have named return values, but returning via expressions is more concise. Go’s named return values can be assigned directly in the function body, but Rust requires explicit returns.

Statements vs Expressions

This is one of the biggest syntactic differences between Rust and Go. Rust distinguishes between statements and expressions:

  • Statement: Performs some action but doesn’t return a value, ends with semicolon ;
  • Expression: Evaluates and returns a value, doesn’t end with semicolon
rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Statement
let y = 6;  // let is a statement, doesn't return value
// let x = (let y = 6);  // Compile error! expected expression, found statement

// Expression
{
    let x = 3;
    x + 1  // This is an expression, returns 4
}

// if is an expression
let number = if condition { 5 } else { 6 };

// loop can return values
let result = loop {
    break 100;
};

In Go, almost everything is a statement, with few exceptions (like func literal). In Rust, if, match, loop, {} blocks can all be expressions, making code more concise.

Key rule: An expression assigned to a variable must not end with a semicolon, otherwise it becomes a statement and returns () (unit type).

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Wrong example
let x = {
    let y = 3;
    y + 1;  // Added semicolon, became statement, returns ()
};
// x's type is (), not i32

// Correct example
let x = {
    let y = 3;
    y + 1  // No semicolon, returns 4
};

References and Slices Basics

Rust’s reference system is the foundation of its ownership model. References allow you to access values without taking ownership:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);  // Borrow s1
    println!("The length of '{}' is {}.", s1, len);  // s1 is still valid

    let s2 = String::from("hello world");
    let hello = &s2[0..5];  // String slice
    let world = &s2[6..11];
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

&s1 creates an immutable reference to s1, and s’s type is &String. When the function body ends, the reference becomes invalid, but s1’s ownership remains unchanged.

Slice syntax [start..end] creates a slice view of a collection. [0..5] means from index 0 (inclusive) to index 5 (exclusive). .. can omit start or end: ..5 means from 0 to 5, 6.. means from 6 to the end.

Go/Rust Comparison:

GoRust
Immutable reference&T or pointer *T&T
Mutable reference*T&mut T
String slices[start:end]&s[start..end]
Borrowing rulesNo constraintsMultiple immutable OR one mutable reference in same scope

Go has no borrowing rules and can have multiple mutable references simultaneously. Rust’s borrowing rules guarantee compile-time memory safety, one of Rust’s core designs.

Ownership Preview

Rust’s ownership is its most unique feature, ensuring memory safety without garbage collection. Basic rules:

  1. Every value in Rust has an owner (owner)
  2. A value can have only one owner at any given time
  3. When the owner goes out of scope, the value is dropped
rust
1
2
3
4
5
6
7
8
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // s1's ownership moves to s2
    // println!("{}", s1);  // Compile error! value borrowed here after move

    let s3 = s2.clone();  // Deep copy, s2 is still valid
    println!("{}", s2);
}

The String type is heap-allocated, and assignment defaults to move semantics, not shallow copy. Go’s string assignment is shallow copy (sharing underlying array), while Rust requires explicit .clone() for deep copy.

We’ll cover ownership in detail in the next post, including borrowing, lifetime, and other core concepts. For now, just have an impression—Rust guarantees compile-time memory safety through ownership, without garbage collection.

Summary

We’ve now covered the core of Rust basic syntax—variable declaration, basic data types, control flow, functions, statements vs expressions, references and slices, ownership preview. You can now read most Rust code.

If you remember one thing: Rust’s syntax balances expressiveness and safety—variables are immutable by default, match is powerful and must be exhaustive, if/match/loop are expressions, borrowing rules guarantee compile-time memory safety.

In the next post, we’ll dive into Rust’s most unique feature: ownership and borrowing—ownership rules, references, lifetime, Copy trait. This is the foundation of Rust’s safe programming and what Go developers need to adapt to most.

References