24 Jan 24

Rust Strings

Let’s learn about Rust strings. As in plural. There are essentially two types. Um, what? You can’t be serious? Well, kinda.

Is this going to be one of those examples that demonstrates why Rust is hard to learn? No, not really. But, as simple as Strings are in other languages, they are a great gateway into the Rust ownership model, stack vs heap, etc… Before we get ahead of ourselves, let’s see some simple examples compared to Ruby.

In Ruby, we can use Strings as values for variables and constants. We’re going to use our Ruby project from the Getting Started post.

# lib/my_project_ruby.rb

module MyProjectRuby
  class Error < StandardError; end
  # Your code goes here...
  puts "hello world"
end

You can see we already use a String in the puts statement. The code “hello world” defines a String value. Let’s add some more examples.

# lib/my_project_ruby.rb

module MyProjectRuby
  class Error < StandardError; end

  NAME = "foo"
  puts NAME.class # => String

  substring = "this is a string".split(" ").last  
  puts substring.class # => String

  puts "hello world".class
end

If you run this file via ruby lib/my_project_ruby.rb from the project root, you will see:

String
String
String

If you are familiar with Ruby all of this should be familiar. We defined a Constant with a String value. Next we defined a variable called substring and used a method on a String to define another String value. Lastly, we used the existing puts statement and rather than printing out the String value, we printed out the class of the value which of course is String. Defining Strings in Ruby is pretty straightforward. You can use double or single quotes.

Let’s look at the equivalent in Rust. Again, we’ll reuse our Rust project from the Getting Started post.

// src/main.rs
const NAME: &str = "foo";

fn main() {
    println!("value of constant is {}", NAME);
    println!("Hello, world!");
}

Here we go! This is where we get into there being two String types in Rust. See how the constant is defined with a type of &str. This is the String literal in Rust. It is most commonly referred to as a string slice. And in its borrowed form. You may already be thinking that Rust Strings are a lot more complex than in a language like Ruby. Tis true. Let’s unpack this and get a good understanding.

Strings can be defined in two ways in Rust.

// this is a string slice type (aka &str) (aka not growable)
let hello_world = "Hello, World";

// this is a String type (aka a growable string)
let hello_world = String::from("Hello, world!");

str is defined here: https://doc.rust-lang.org/std/str/index.html.

String is defined here: https://doc.rust-lang.org/std/string/struct.String.html

Ok, why are there two String types? This is where we have to get into how Rust manages memory and performs so well. Rust manages memory using a Stack and a Heap. If you want a brief but concise overview, I suggest the Rust book. In the Ownership section there is a great break out section that describes the Stack vs the Heap.

Notice in the comments, I wrote aka not growable and aka a growable string. Growable in this sense means, we know the exact value at the time we define the value, or we don’t know the entire value at the time, or suspect it will change. In the first statement where we define hello_world using a &str value, we know the entire value and it is not going to change. Based on this information, Rust can place this variable and its value on the Stack. It’s concrete and therefore we can be precise about the amount of memory we need. This makes it fast to retrieve. The Rust book explains: “In the case of a string literal, we know the contents at compile time, so the text is hardcoded directly into the final executable. This is why string literals are fast and efficient.”

Let’s see what happens when we try to grow it: // src/main.rs const NAME: &str = “foo”;

fn main() {
    println!("value of constant is {}", NAME);

    let hello_world = "Hello World";
    hello_world.push_str("! May I borrow you?");

    println!("{}", hello_world);
}

When we run this with cargo run, we receiving the following error: error[E0599]: no method named push_str found for reference &str in the current scope –> src/main.rs:8:17 | 8 | helloworld.pushstr(“! May I borrow you?”); | ^^^^^^^^ method not found in &str

push_str is a method used to concatenate a String. Notice it doesn’t exist on &str. That is our first clue that we should think of &str values as not able to be changed.

In the second let we use String::from to define the value. Image we are collecting user input from a form. We don’t know the value the user would provide, so we take that input and use it to define a String. Because we don’t know the explicit and complete value, we use a String type and Rust stores this value on the Heap. This gives us a slot in memory that will be accommodating for various lengths of strings (with defined bounds). If we’re accepting user input, we may not know the number of characters they are going to enter, so we need variable space. Say something between 0 and 64 characters for example. Rust’s String struct contains an address, length and capacity. Capacity being the maxed allowed size.

Let’s see what happens when we try to grow that value: // src/main.rs const NAME: &str = “foo”;

fn main() {
    println!("value of constant is {}", NAME);

    let mut hello_world = String::from("Hello World");
    hello_world.push_str("!"); // add the exclamation

    println!("{}", hello_world);
}

This compiles and the result is: Hello World!

Notice we used mut to make the variable mutable, but the point isn’t about mutatability. We are talking about known values. Maybe we should think of them as hard coded vs dynamic values. In Ruby, NAME = "foo".freeze would be a &str because the value would be known, essentially hardcoded, should there be a compile step. Obviously, the same classes and objects don’t exist in both Ruby and Rust, so It’s not apples to apples. We’re simply using them to illustrate the point.

I was going to cover borrowing now since I mentioned it before, but it’s a topic all its own. For simple terms, let’s say borrowing means you reference values (and memory slots) rather than copying the value for a specific scope. We’ll cover it in a lot more detail in a future post.

So let’s stick to strings. Let’s complete this post with a look at how to do common things regardless of language. First a few common examples in Rust.

// src/main.rs
fn main() {
    # basic assignment
    let hello_world = "Hello World";

    // print a string
    println!("{}", hello_world);

    // concatenate a string
    let mut name = String::from("Steve");
    name.push_str(" Jobs");
    println!("name: {}", &name);

    // determine the length of a string (answer: 3)
    let len = "foo".len();
    println!("len: {}", &len);

    // determine the index of a substring
    let index: Vec<_> = "foobar".match_indices("bar").collect();
    println!("index: {}", &index[0].0);

    // trim newlines and spaces
    let trim = " \nfoo \n ".trim();
    println!("trimmed: {}", trim);

    // debug/display trait
    let debug_str = "this is a &str";
    dbg!(debug_str);

    let debug_string = String::from("this is a String");
    dbg!(debug_string);
}

And now the equivalent in Ruby:

module MyProjectRuby
  class Error < StandardError; end

  # basic assignment
  hello_world = "hello world!"

  # print a string
  puts hello_world

  # concatenate a string
  name = "Steve";
  name += " Jobs";
  puts name

  # determine the length of a string (answer: 3)
  len = "foo".size
  puts "len: #{len}"

  # determine the index of a substring
  index = "foobar".index("bar")
  puts "index: #{index}"

  # trim newlines and spaces
  trim = " \nfoo \n ".strip();
  puts "trimmed: #{trim}"

  # debug/display trait
  debug_str = "this is a string";
  puts debug_str.inspect
end

That gives a basic overview of Strings in Rust compared to Ruby. In the next post, we’ll get into the various other common objects related to numbers.