Ruby's Frozen String Comment: YAGNI
- 5 minutes read - 938 wordsOpen 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.
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) }
end
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
Style/FrozenStringLiteralComment:
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.