December Code

Rust data types interview questions, with answers

Rust's type system does more work than most: it decides where values live, who may change them and which conversions are allowed, all at compile time. Interview questions on the basics therefore go further than listing types. They ask why variables are immutable unless you say otherwise, how a String differs from a &str, why indexes are usize, and why Rust makes you convert numbers explicitly.

The answers below take those questions in order, from the scalar and compound types to inference and conversions, with code compiled and run on rustc 1.98. Then take the free Rust diagnostic — ten questions across every Rust topic in the bank — to see which of these you can explain but not yet predict.

The questions, with answers

  1. 1.What are Rust's scalar and compound types?

    In short: Scalars are integers, floats, bool and char; the built-in compound types are tuples and fixed-size arrays.

    Rust's integers come in explicit widths, i8 to i128 and u8 to u128, plus isize and usize, which match the pointer size. Floats are f32 and f64, bool is true or false, and char is a four-byte Unicode scalar value, so 'z' and '€' are both one char. A tuple groups values of different types, such as (i32, char), and is read with .0 and .1. An array, such as [1u8; 4], has a fixed length that is part of its type and lives inline, usually on the stack. Growable sequences are library types built on these, Vec<T> and String, rather than built-in types.

    let t: (i32, char) = (7, 'z');
    let a = [1u8; 4];
    println!("{} {} {}", t.0, t.1,
        a.len());
    // 7 z 4
  2. 2.Why are variables immutable by default in Rust?

    In short: Because most values never need to change, and making change opt-in with mut lets both readers and the compiler rely on a value staying the same.

    A binding made with let cannot be reassigned or modified; adding mut, as in let mut count = 0, allows it. Making mutation opt-in means a reader can see at the declaration which variables change, and it underpins the borrowing rules: the compiler can hand out any number of shared references to a value precisely because nobody may change it through them. It also removes a class of bugs where a value changes unexpectedly between two uses. Immutability applies to the binding and what it owns, so a Vec in an immutable binding cannot be pushed to either. mut is common and not discouraged; it just has to be stated.

    let mut count = 0;
    for _ in 0..3 {
        count += 2;
    }
    println!("{count}");
    // 6
  3. 3.What is the difference between const, static and let in Rust?

    In short: let binds a local value at run time; const is a compile-time value copied into each use; static is a single value at a fixed address for the whole program.

    let creates a local variable whose value can be computed at run time. const declares a named compile-time constant: its type must be written, its value must be computable during compilation, and the compiler inlines it wherever it is used, so it has no single address. static declares one value that lives for the entire program at a fixed memory location, which is what a global needs when its address matters or it is large; a static mut can be changed only inside unsafe code, since the compiler cannot check access from several threads. Both const and static can be declared outside functions, and by convention use upper-case names.

    const MAX: u32 = 3;
    static NAME: &str = "cfg";
    let m = MAX * 2;
    println!("{m} {NAME}");
    // 6 cfg
  4. 4.What is the difference between String and &str in Rust?

    In short: String owns a growable UTF-8 buffer on the heap; &str is a borrowed view of UTF-8 text stored somewhere else, such as in a String or the program's binary.

    A String owns its text: it allocates a buffer, can grow with push_str, and frees the buffer when it is dropped. A &str, a string slice, owns nothing; it is a pointer and a length into text that lives elsewhere. String literals are &'static str, pointing into the compiled program, and borrowing a String, as &own below, gives a &str over its buffer. Functions that only read text should take &str, because both a String and a literal can be passed to them. Convert a &str into an owned String with String::from, to_string or to_owned when the value must outlive its source.

    let lit: &str = "hi";
    let mut s = String::from(lit);
    s.push_str(" there");
    let view: &str = &s;
    println!("{view}");
    // hi there
  5. 5.What is usize in Rust, and why are indexes usize?

    In short: usize is an unsigned integer the size of a pointer, and indexes and lengths use it because they measure positions in memory, which can never be negative.

    usize is 64 bits on 64-bit targets and 32 bits on 32-bit ones, the same width as a memory address, so it can represent the size of any object the program can hold. That is why len returns usize and why indexing a slice or a Vec requires one: an index is an offset into memory, and it can never be negative. Other integer types are not converted automatically, so code holding an i32 index has to write v[n as usize] or, when the value might be negative, check it first with usize::try_from. isize is the signed counterpart, used for offsets and differences between positions.

    let v = vec![7, 8, 9];
    let i: usize = 2;
    println!("{}", v[i]);
    // 9
    let n: i32 = 1;
    println!("{}", v[n as usize]);
    // 8
  6. 6.How does type inference work in Rust, and when do you need annotations?

    In short: The compiler infers most local types from how values are used; you annotate function signatures, and wherever several types would fit, such as parse and collect.

    Rust infers the types of local variables from their initial values and later uses, even across statements, so most let bindings need no annotation. Function signatures are the exception: parameter and return types must always be written, which keeps inference local to a function body. Some calls could produce many types, so the compiler needs to be told which one: parse can yield any type that implements FromStr, and collect can build any collection. Either annotate the variable, as with Vec<char> below, or use the turbofish syntax on the call, parse::<i32>(). When inference fails, the compiler says which type it could not determine.

    let n = "42".parse::<i32>()
        .unwrap();
    let v: Vec<char> =
        "abc".chars().collect();
    println!("{} {:?}", n + 1, v);
    // 43 ['a', 'b', 'c']
  7. 7.How do you convert between numeric types in Rust?

    In short: Rust never converts implicitly: use From or into for lossless widening, TryFrom for checked narrowing, and as for an unchecked cast.

    Arithmetic between different integer types does not compile until one side is converted. Conversions that can never lose information implement From, so i64::from(7u8) or a let with a type and .into() widen safely. Conversions that might not fit implement TryFrom: u8::try_from(big) returns an Err for 300, forcing the caller to handle the failure, as below. The as keyword casts anything numeric without checking, truncating integers and saturating floats, which is fast and occasionally what you want, but it hides bugs when a value is out of range. Code reviews in Rust treat an as between integer types as something to justify.

    let big: i64 = 300;
    let r = u8::try_from(big);
    println!("{}", r.is_err());
    // true
    let w: i64 = i64::from(7u8);
    println!("{w}");
    // 7

How the diagnostic asks it

One question from the Rust bank, exactly as a sitting would show it. The bank has 5 on variables & types and 30 across Rust.

Variables & Types · easyRUST-004

What does this Rust code print?

let big: i32 = 300;
let small = big as u8;
println!("{}", small);
  1. 1255
  2. 244correct
  3. 3It panics: 300 does not fit in a u8
  4. 4It does not compile: an i32 cannot be cast to a u8

as between integer types never fails: narrowing keeps the low-order bits and drops the rest. 300 is 256 + 44, so as a u8 it becomes 44. 255 describes a saturating conversion, which is what as does for floating-point to integer casts, not integer to integer. It does not panic, which is exactly why as is risky for narrowing; for a checked conversion, use u8::try_from(big) or big.try_into(), which return an Err for 300. The cast itself is legal, and is also how Rust converts between numeric types in the other direction, since there are no implicit numeric conversions.

Measure it

Reading answers tells you what’s true. A diagnostic tells you what you get wrong.

10 Rust questions across its topics, easy to hard, about fifteen minutes. You get a readiness figure with the arithmetic shown, the topics you missed named, and a practice set sized for today. Free: 1 diagnostic a month and 15 problems a day. No card.

What the readiness test measures · how the score is computed

By Harshit · updated