Jake Worth

Jake Worth

Ruby's Frozen String Comment: YAGNI

Published: February 09, 2022 • Updated: January 03, 2023 5 min read

  • ruby

Open a production Ruby file, and you’ll often see this magic comment at the top.

# frozen_string_literal: true

Today I’d like to argue that most Ruby files do not need this comment. You aren’t going to need it (YAGNI) and the norm should be exclusion.

Why? I believe it:

  • Is a relic of the past
  • Doesn’t do what people think
  • Isn’t Ruby-ish
  • Has a debatable impact on performance

I’d like to see it deleted from the code I work on, and I don’t think we should be enforcing its presence most of the time.

So, what is a magic comment? Here’s an explanation from the Ruby docs:

While comments are typically ignored by Ruby, special “magic comments” contain directives that affect how the code is interpreted.

This particular magic comment, however, has a unique history.

It’s a Relic

“All String literals are immutable (frozen) on Ruby 3.” —Ruby creator Matz

The frozen_string_literal magic comment was added to Ruby to prepare codebases for frozen strings being the default in Ruby 3. Ruby 3 is now available, and we know that it did not become the default. So, what happened? Matz changed his mind:

“I consider this for years. I REALLY like the idea but I am sure introducing this could cause HUGE compatibility issue, even bigger than Ruby 1.9. So I officially abandon making frozen-string-literals default (for Ruby3).” —Matz

In other words, this comment was a shim to prepare our codebases for a future change that is never going to happen. It’s a deprecation warning that is not ever going to be resolvable. It’s a relic.

It’s Misunderstood

Even worse, this magic comment is frequently misunderstood.

The comment behaves as if .freeze had been called on each string. A common understanding of .freeze is that it makes the string immutable. Let’s look at that.

Here’s some Ruby code to demonstrate freezing. We’ll create a string, .freeze it, verify it is frozen, and read the object ID.

irb> frozen = "frozen"
=> "frozen"
irb> frozen.freeze
=> "frozen"
irb> frozen.frozen?
=> true
irb> frozen.object_id
=> 70348222862920

So far, so good. Here’s what many Rubyists, including me quite recently, think you can’t do to this frozen string: addition assignment.

> frozen += " like ice"
=> "frozen like ice"

Whoops! Didn’t we just append to an immutable object? How is that possible? It’s possible because… it’s a new object.

> frozen.object_id
=> 70348222814640 # was 70348222862920

So what can’t we do? We can’t append to the string with <<:

> frozen.freeze
=> "frozen like ice"
> frozen << " and snow"
RuntimeError: can't modify frozen String

When it comes to modifying strings, my experience is that += is a much more common syntax than << or .concat. Such that I’ve never even seen this runtime error before.

If this was just one person’s misunderstanding, okay. However, I’ve heard people use addition assignment to explain this feature many times. It’s how people think the feature ought to work. It doesn’t work that way, and that’s a problem.

It’s Not Ruby-ish

Ruby operates on the “principle of least surprise”, and it is surprising to me when comments in Ruby are evaluated. I feel that magic comments detract from the readability of a Ruby file because I have to know something unusual to read the code. Rubyists expect comments to be unevaluated documentation.

I didn’t mind this unusual syntax when it was a shim. Now that the language has committed to a different direction, why are we holding onto it?

Preemptive Performance

A common argument in support of this comment is that it creates more performant code. Like any preemptive performance claim, we need to prove it!

This post from Honeybadger includes a benchmarking script that demonstrates a measurable performance gain when using .freeze. I’ve copied it here:

# test.rb

require 'benchmark/ips'

def noop(arg); end

Benchmark.ips do |x|
  x.report('normal') { noop('foo') }
  x.report('frozen') { noop('foo'.freeze) }

Here’s the result of this script when I ran it on Ruby 2.4:

$ ruby test.rb
Warming up --------------------------------------
              normal   755.202k i/100ms
              frozen   932.701k i/100ms
Calculating -------------------------------------
              normal      7.596M (± 0.8%) i/s -     38.515M in   5.071109s
              frozen      8.895M (± 0.7%) i/s -     44.770M in   5.033199s

Unfrozen completes 7.596M iterations per second, while frozen completes 8.895M iterations per second. That’s statically significant! Does that mean we should use .freeze all the time?

It depends. I believe that performance can be an ‘Appeal to Common Belief’ fallacy; it’s easy to defend and you’re on your heels anytime you argue against it. Many premature, inscrutable programming decisions have been made in performance’s name.

Optimizations like this need to prove that they matter. Does decorating your code with magic comments make a difference? We must prove it because every choice has tradeoffs. In this case, requiring the comment introduces the following tradeoffs: wasted space, lower readability, unearned confidence that the codebase is extra performant, failing CI builds when it is required but absent, and more.

I want to solve performance problems that matter. If you’ve decided it does matter, run your program with the CLI flag (thanks Dillon Hafer):

$ your_process.rb --enable-frozen-string-literal

It’s Everywhere

This comment is very prevalent in Rails apps. Why is that? Rubocop. The popular Ruby linter enforces this feature without prejudice.

If you don’t care about the comment and are instead trying to appease the linter, why not achieve the same by just ignoring it? Disable that cop:

# .rubocop.yml

  Enabled: false

Wrapping Up

I’m grateful to the Ruby team for proposing this optimization. Enhancing performance while keeping Ruby Ruby is a noble pursuit.

I believe that this comment is deletable most of the time. Consider the value this comment brings to your code.

What are your thoughts on this? Let me know!

Join 100+ engineers who subscribe for advice, commentary, and technical deep-dives into the world of software.