Chapter 15

Structs and Methods

A struct works as a waiter at a bar.
“Why do you look so sad?” asks a colleague.
“A tuple just treated me like an object.”
“Don’t worry. It has no class.”

In Rust, a method's signature tells you whether it borrows a value, changes it, or takes ownership of it. You group related fields in a struct and attach methods with an impl block.

struct Playlist {
    tracks: Vec<String>,
}

impl Playlist {
    /// No receiver is needed to create an empty playlist.
    fn new() -> Self {
        Self { tracks: Vec::new() }
    }

    /// A shared borrow is enough to inspect the playlist.
    fn len(&self) -> usize {
        self.tracks.len()
    }

    /// Adding a track changes the playlist, so it needs a mutable borrow.
    fn add(&mut self, track: String) {
        self.tracks.push(track);
    }
}

let mut playlist = Playlist::new();
playlist.add("Blue in Green".to_string());
assert_eq!(playlist.len(), 1);

The form of self tells you what access the method receives:

Field access uses dot notation (playlist.tracks). Inside impl you write self.field for the same thing.

Self (capital S) is shorthand for "the type I'm impling". Playlist and Self are interchangeable inside impl Playlist.

A Note on Ranges: 0..5

The record_login test calls the method five times in a loop:

for _ in 0..5 {
    user.record_login();
}

0..5 is a range expression. It produces the values 0, 1, 2, 3, 4 (end-exclusive). 0..=5 is the inclusive variant if you want 5 too. The loop variable is _ here because the body doesn't need it; we just want "do this thing five times." Ranges are useful as iterators but also work as slice indices (v[0..3]).

From this chapter onward the files get longer, and the in-browser editor starts feeling cramped. The Open in Web Editor button above each editor opens this file on github.dev, a full browser-based VS Code with proper find-in-file, multi-cursor, and the rest of the keyboard shortcuts you'd expect. Clone the repo locally if you want rust-analyzer and on-save formatting.

Defining a Struct and a Constructor

A struct groups related fields under one name. Rust has no built-in constructors; the convention is an associated function called new that returns Self. "Associated" means it lives in the impl block but doesn't take self. You call it as User::new(..).

You'll write a constructor for User so every new account starts unverified, with zero logins recorded.

Useful Resources

  • The Rust Book on structs covers struct literals and field shorthand: write email instead of email: email when the names match.
  • Self (capital S) is interchangeable with the struct's name inside an impl block. Returning Self keeps the constructor signature stable if you ever rename the type.
Exercise 1 of 4
Open in Web Editor

Results

    Compiler / runtime output
    
                
    Reveal the full solution Spoiler: the complete answer
    #[derive(Debug, PartialEq)]
    struct User {
        email: String,
        name: String,
        is_verified: bool,
        login_count: u32,
    }
    
    impl User {
        /// Creates a new user account. New users start unverified with 0 logins.
        fn new(email: String, name: String) -> Self {
            Self {
                email,
                name,
                is_verified: false,
                login_count: 0,
            }
        }
    }
    
    #[test]
    fn test_new() {
        let user = User::new("alice@example.com".to_string(), "Alice".to_string());
        assert_eq!(user.email, "alice@example.com");
        assert_eq!(user.name, "Alice");
        assert_eq!(user.login_count, 0);
        assert_eq!(user.is_verified, false);
    }
    

    Methods That Borrow `&self`

    A method taking &self reads the struct's fields without modifying or consuming it. You'll use this form whenever a method only needs to inspect the value. Inside the method, self behaves like any other reference, so you can read fields freely and the caller keeps ownership.

    display_name formats two fields into a new String. Use format! rather than building the string by hand.

    Useful from the Standard Library

    • format! builds a new String from a template and arguments. It uses the same syntax as println!, but returns the string instead of printing it.
    • Field access uses dot notation: self.name, self.email.
    Exercise 2 of 4
    Open in Web Editor

    Results

      Compiler / runtime output
      
                  
      Reveal the full solution Spoiler: the complete answer
      #[derive(Debug, PartialEq)]
      struct User {
          email: String,
          name: String,
          is_verified: bool,
          login_count: u32,
      }
      
      impl User {
          const fn new(email: String, name: String) -> Self {
              Self {
                  email,
                  name,
                  is_verified: false,
                  login_count: 0,
              }
          }
      
          /// Returns the user's display name for the UI. Format: "{name} ({email})"
          fn display_name(&self) -> String {
              format!("{} ({})", self.name, self.email)
          }
      }
      
      #[test]
      fn test_display_name() {
          let user = User::new("alice@example.com".to_string(), "Alice".to_string());
          assert_eq!(user.display_name(), "Alice (alice@example.com)");
      }
      

      Methods That Mutate via `&mut self`

      When a method needs to change the struct's data, it takes &mut self. Declare user with let mut so the method can borrow it mutably. At user.record_login(), Rust supplies the &mut borrow automatically.

      record_login bumps a counter and flips a flag. You can set is_verified = true even if the user is already verified. That assignment is idempotent, so you don't need a branch to check the flag first.

      Updating Fields

      Exercise 3 of 4
      Open in Web Editor

      Results

        Compiler / runtime output
        
                    
        Reveal the full solution Spoiler: the complete answer
        #[derive(Debug, PartialEq)]
        struct User {
            email: String,
            name: String,
            is_verified: bool,
            login_count: u32,
        }
        
        impl User {
            const fn new(email: String, name: String) -> Self {
                Self {
                    email,
                    name,
                    is_verified: false,
                    login_count: 0,
                }
            }
        
            /// Records a successful login attempt.
            ///
            /// Increments `login_count` and sets `is_verified` to `true`. The
            /// verification flag is idempotent: setting it on every login is fine
            /// because once you're verified you stay verified. (A real system would
            /// only set it on first login; we keep it simple here.)
            fn record_login(&mut self) {
                self.login_count += 1;
                self.is_verified = true;
            }
        }
        
        #[test]
        fn test_record_login() {
            let mut user = User::new("alice@example.com".to_string(), "Alice".to_string());
        
            user.record_login();
            assert_eq!(user.login_count, 1);
            assert_eq!(user.is_verified, true);
        
            user.record_login();
            assert_eq!(user.login_count, 2);
            // Verification stays on across subsequent logins (idempotent).
            assert!(user.is_verified);
        }
        

        Predicates over Multiple Fields

        If you keep writing user.is_verified && .. in different places, give that rule a name. A method lets you keep the checks together and tells callers what they're asking: can this user access premium features?

        can_access_premium combines two conditions into a single bool. In Rust, the body of a function is an expression, so you can just write the boolean expression with no return and no semicolon.

        Returning a Boolean Expression

        Exercise 4 of 4
        Open in Web Editor

        Results

          Compiler / runtime output
          
                      
          Reveal the full solution Spoiler: the complete answer
          #[derive(Debug, PartialEq)]
          struct User {
              email: String,
              name: String,
              is_verified: bool,
              login_count: u32,
          }
          
          impl User {
              const fn new(email: String, name: String) -> Self {
                  Self {
                      email,
                      name,
                      is_verified: false,
                      login_count: 0,
                  }
              }
          
              const fn record_login(&mut self) {
                  self.login_count += 1;
                  self.is_verified = true;
              }
          
              /// Checks if user can access premium features. Requires verification and at
              /// least 5 logins.
              fn can_access_premium(&self) -> bool {
                  self.is_verified && self.login_count >= 5
              }
          }
          
          #[test]
          fn test_can_access_premium() {
              let mut user = User::new("alice@example.com".to_string(), "Alice".to_string());
          
              // Not enough logins yet
              assert_eq!(user.can_access_premium(), false);
          
              user.record_login();
              assert_eq!(user.can_access_premium(), false);
          
              for _ in 0..3 {
                  user.record_login();
              }
              // Verified, but still one login short.
              assert_eq!(user.can_access_premium(), false);
              user.record_login();
              assert_eq!(user.can_access_premium(), true);
              user.record_login();
              assert_eq!(user.can_access_premium(), true);
          
              // Enough logins alone cannot grant access if verification is revoked.
              user.is_verified = false;
              assert_eq!(user.can_access_premium(), false);
          }
          

          Wrapping Up Structs and Methods

          You defined a struct, wrote a new constructor, added a &self method that formatted fields into a String, mutated state with &mut self, and combined two fields into a predicate.

          What We Learned

          • A struct groups related fields under a single named type. Build instances with a struct literal, supplying every field, and read fields with dot notation.
          • An impl block attaches functions to the type. Without self, it's an associated function (called as User::new(..)); with self, it's a method (called as user.method()).
          • The three flavors of self say what the method intends to do: &self reads, &mut self mutates in place, plain self consumes. These are the same ownership choices you've already made with function parameters.
          • Self (capital S) inside an impl block is shorthand for the type. Returning Self keeps the constructor signature stable if the type is later renamed.
          • format! is the idiomatic way to build a String from a template; same syntax as println! but returns the string.
          • #[derive(Debug, PartialEq)] covers the common pair: {:?} printing for debugging and == for tests. Derive Default, Clone, or Copy only when the generated behavior matches the type.
          • Encoding business rules as predicates on the type (user.can_access_premium()) keeps the checks in one place and gives callers a name for the rule.
          Next chapter 16A Primer on Lifetimes

          Optional Chapters

          Extra practice to explore at your own pace. These chapters do not count toward course progress.

          ← Return to the Rust course curriculum