Cost of allocating String returned by function

Warning: embarrassingly naive question

So of course I want my code to be DRY but whenever I try to tuck some String procesing logic into a function I am suddenly concerned about how it must now return a new String allocation, whereas without the function I avoid a new allocation yet I suffer code duplication.

For instance, let's say I have some silly function in performance-critical code:

fn what_matters(slice: &str) -> String {
  // in reality, more could be done by this function... just a small example...
  slice[1..slice.len() - 1].to_string()
}

Everywhere I call what_matters I do not own the String, it is read from a file I am parsing. So I'd like to avoid allocating a String, which I achieve by not using what_matters.

However, sometimes I end up duplicating little String operations like this, and so I would have a cleaner development experience reusing what_matters instead, but then aren't I then performing String allocations unnecessarily everywhere what_matters is called?

It makes me wonder... should I invest in macros instead to avoid the allocation and yet still make my code DRY?:

macro_rules! what_matters {
  ($slice:expr) => {{
    $slice[1..$slice.len() - 1].to_string()
  }}
}

I know I should benchmark my code before getting all worried like this, but this comes up so frequently I wouldn't have time in each scenario to benchmark. I don't want to litter my code base with little bottleneck landmines if I can avoid it, so is there general truth to this and possible rules of thumb I should follow?

If you didn't need the String without the function, you won't need it with the function, either. E.g. your example could be rewritten as:

fn what_matters(slice: &str) -> &str {
  // in reality, more could be done by this function... just a small example...
  &slice[1..slice.len() - 1]
}
8 Likes

From the body of your question, it sounds like you are mixing a performance concern (unnecessary string copies) and a code duplication concern (writing similar string operations multiple times). The abstraction concern is largely solved by introducing helper functions (possibly wrapped up into an extension trait so you get method syntax), so I'll focus on the performance aspect.

No. In general, macros aren't a way to make code faster.

This used to be a thing back in the 80s and 90s where you'd abuse C's preprocessor to avoid the cost of a function call, but today's compilers have an "inliner" which will automatically copy the body of small functions into their caller (essentially what the macro does). So nowadays, using a macro over a function call just makes your code harder to maintain.

If you don't need to create a new string (e.g. because you are just returning a sub-string rather than doing a string.replace()) then @H2CO3's function which returns a &str is probably what you are looking for.

Cloning small strings isn't particularly expensive in practice unless the strings you are cloning are very large and you are doing a lot of them.

If you have measured your code and know that string copying really is a bottleneck, there are a couple tricks where you use domain-specific knowledge to optimise certain use cases.

  • Make your operations return &str slices into their arguments so the caller can choose whether they need to call to_string() to get an owned version (e.g. to store it in a long-lived object)
  • If all your strings are really small (e.g. 22 bytes or less), reach for a string type with a small-size optimisation like the smol_str crate
  • If you have a really big string and will be creating lots of objects which point at sub-sections of the string all with roughly the same lifetime and no modifications (e.g. an AST), consider something with structural sharing like the bytestring crate
  • If you will be doing a lot of mutations to very large strings (e.g. in a text editor), consider using a Rope data structure like the ropey crate

However, I'd like to stress that other than the first one, these are all optimisations you should only make when you know (i.e. with numbers) that strings are a performance issue.

As I recently reminded a co-worker,

You wouldn't bat an eyelid if I wrote this JavaScript, would you?

const name = "Michael"; 
const greeting = "Hello"; 
console.log(greeting + ", " + name + "!");

Yet depending on how smart the JIT is, that would potentially involve allocating+copying 5 separate strings ("Michael", "Hello", "Hello, ", "Hello, Michael", "Hello, Michael!").

9 Likes

As an aside, I question how comparable that is — to what extent does JavaScript offer options for doing this ‘more efficiently’?

Not being very familiar with modern JavaScript, I wonder whether JS has something like Rust's format_args or a printf. I see Template literals (Template strings) - JavaScript | MDN but I imagine one avoids that[1] to support older Web browsers like Internet Explorer?


  1. or transpiles it to repeated string addition? ↩︎

Template "literals" is exactly what you'd use. Basically every "serious" project written in JS ends up transpiling to something digestible even by IE from 1990 (-ish) and minify the result anyway, but decent browsers released in, say, the last 5 years all support these language features natively.

Incidentally, it makes me chuckle when people talk about the great and fast feedback loop of JS. I have worked on JS code bases that compiled slower than a comparable Rust crate. Yay modern technology!

If you still want to avoid template literals and Shlemiel concatenation, you can put the individual pieces in an array and call array.join().

2 Likes

You nailed it - my example was too trivial! str.replace(...) is a better example. For that kind of operation I thought you couldn't return &str references because it is owned by the function and the lifetime will end once returned. In my example I forgot non-mutating operations can maintain ownership in the caller.

but today's compilers have an "inliner" which will automatically copy the body of small functions into their caller

So will the compiler copy of the body of small functions into their caller, changing the return type from String to &str to avoid the heap as well?

You seem to be confused about this. You don't get an &str from replace() even when you don't put it in a separate function, but this has next to nothing to do with ownership and borrowing.

All this has to do with is that replacement inherently needs to allocate a new string. And the reason for this is simply that the replacement might be longer than what it is replacing, so there is no way to perform this operation in-place in the general case. For example, "foobar".replace("oo", "longstring") results in "flongstringbar", which is longer than the original. (The only way to do this in-place would be if replace took a &mut String which it could then freely extend and move its contents around, but that's just not possible given that it takes a &str, for reasons of convenience.)

Similarly, inlining has nothing to do with optimizing away heap allocations. (Although inlining can and does enable additional optimizations by giving more context to downstream optimization passes, allowing them to "see through" function calls.) A superfluous heap allocation can, and is often, optimized away by the compiler. However, the heap allocation in replace() is not superfluous in general, as I explained above, and so it can't usually just be optimized away.

That's the same as the function. It's the .to_string() part that allocates, not whether you're returning it from a function or not.

3 Likes