skip to content

In Ruby, what do unary +"..." and -"..." (String#+@ and String#-@) return, and when would you use each?

level: seniorimportance: should knowfreq 30%

answer

  1. plus: mutable, minus: frozen
  2. +@ dups only when needed
  3. -@ returns a deduplicated string
  4. dedup is the alias of -@
  5. method call binds tighter than unary

basics

~20 s

+str returns str itself when it is already mutable without warning, otherwise an unfrozen copy. -str returns a frozen, deduplicated string equal to str, so equal strings share one object. Use + for buffers, - for repeated values.

solid answer

~50 s

`String#+@` answers "give me something I may mutate": it returns the receiver if it is not frozen and would not warn, otherwise `dup`. That makes `buf = +""` the standard mutable buffer under `# frozen_string_literal: true`, and it replaced the `str.dup if str.frozen?` idiom. `String#-@`, aliased as `dedup`, answers "give me the one shared frozen copy": it returns a frozen string from Ruby's deduplication table, so `(-a).equal?(-b)` is true for two plain `String` objects with the same content and encoding. It does not freeze a mutable receiver; it returns a frozen copy. Use it for values repeated many times in memory, such as parsed status codes or carrier names kept in thousands of records, and for constants. Mind precedence: `-"x".upcase` applies `-` to the result of `upcase`, which is why `dedup` reads better in chains.

code

ruby · 10 lines
ruby
# frozen_string_literal: true
buf = +""          # mutable, source encoding
buf << "ALERT"

lines = ["sent\n", "sent\n", "failed\n"]
statuses = lines.map { |l| l.chomp.dedup }
statuses.map(&:object_id).uniq.size   # => 2, one per distinct status

(-"x").frozen?      # => true
-"sms".upcase        # same as -("sms".upcase)

go deeper

for a junior

Recall that +"" gives a mutable string and -"" gives a frozen one; use +"" when you need a buffer in a frozen-literal file.

for a middle

Explain when +@ returns the receiver and when it copies, and what dedup returns for two equal strings built at run time.

for a senior

Show where dedup cuts memory, such as repeated statuses parsed from large reports, and the precedence trap that makes the dedup alias safer in chains.

for a principal

Judge when deduplication pays: it saves memory only when values really repeat, and on mostly unique input it adds a table lookup per string for no gain.

## Two unary methods on String Ruby lets classes define unary operators. For `String`, the prefix forms `+str` and `-str` call the methods `String#+@` and `String#-@`. They are the two halves of Ruby's frozen-string toolkit: | Expression | Returns | Frozen? | Same object as receiver? | |---|---|---|---| | `+str` on a mutable string | `str` itself | no | yes | | `+str` on a frozen or chilled string | `str.dup` | no | no | | `-str` on a mutable string | the deduplicated frozen string equal to `str` | yes | no | | `-str` on a frozen string | the deduplicated frozen string equal to `str` | yes | possibly, if `str` itself is the registered copy | | `str.dedup` | same as `-str` | yes | as above | ## String#+@: a string you may mutate `+str` is a cheap way to guarantee mutability: - If the receiver is **not frozen** and mutating it would not emit a warning, it returns the receiver. No allocation. - If the receiver is **frozen**, or **chilled** (a Ruby 3.4+ literal from a file without a `frozen_string_literal` comment), it returns a `dup`, an unfrozen copy. This makes it the idiomatic buffer starter: ```ruby # frozen_string_literal: true def broadcast_body(lines) body = +"" # "" alone would be frozen here lines.each { |l| body << l << "\n" } body end ``` It also replaced the older idiom `str = str.dup if str.frozen?`, and since Ruby 3.4 it handles chilled literals the same way, so `+"text"` never triggers the chilled-string warning. Compared with `String.new`: `String.new` with no argument returns an empty string in the binary `ASCII-8BIT` encoding, while `+""` keeps the source encoding (normally UTF-8). For text buffers `+""` is the safer default. ## String#-@: one frozen copy per distinct value `-str` returns a **frozen** string equal to `str`, taken from Ruby's internal table of deduplicated strings: 1. If an equal string is already in the table, you get that object. 2. Otherwise the value is registered: a frozen receiver can itself become the shared copy, while a mutable receiver gets a frozen copy registered in its place. 3. A mutable receiver is **not** frozen in place; it stays mutable and you receive a separate frozen string. The result: every call with equal content returns the same object. ```ruby a = "carrier-" + "vodafone" b = "carrier-" + "vodafone" a.equal?(b) # => false (-a).equal?(-b) # => true (-a).frozen? # => true ``` Where it pays off: - **Repeated values from parsing.** A delivery report with a million rows but a dozen distinct status strings can keep a dozen objects instead of a million. - **Constants.** `CONFIG_PATH = -"config/sms.yml"` yields a frozen, shared constant even without the magic comment. String literals in a file with `# frozen_string_literal: true` are already frozen and deduplicated, so `-` on them adds nothing. ## Precedence and the dedup alias Unary operators on a literal bind **more loosely than a method call**: - `-"sms".upcase` is `-("sms".upcase)`: the deduplicated form of `"SMS"`. - `(-"sms").upcase` returns a new unfrozen `"SMS"`, because `upcase` builds a new string. When chaining, the alias `dedup` avoids the parentheses: `status.strip.dedup`. ## Common mistakes - Treating `+str` as always allocating. It copies only frozen or chilled receivers, so it is safe to use defensively on hot paths. - Expecting `-str` to make the original immutable. The receiver keeps its state; only the returned object is frozen. - Deduplicating strings that are almost all unique, such as message bodies or phone numbers. Each call still hashes the string and looks it up, and nothing is saved. - Mutating a deduplicated string after reaching it through another path. It is frozen, so the attempt raises `FrozenError`, and it may be an object other code relies on. ## Choosing - Need to **append or edit**? Start from `+str`. - Need to **keep many copies** of the same text, or a constant? Store `-str` (or `str.dedup`). - Never mutate the result of `-str`: it is frozen, and it may be the same object other code is holding.

  • Does -str freeze the receiver when str is mutable?
    No. `String#-@` leaves a mutable receiver mutable and returns a separate frozen string equal to it, taken from or added to the deduplication table. Only an already frozen receiver can itself become the shared copy.
  • Why prefer +"" over String.new for a text buffer?
    `String.new` with no argument returns an empty string encoded as `ASCII-8BIT`, a binary encoding. `+""` returns a string in the source encoding, normally UTF-8, which is what text built from literals expects.
  • What does +str cost when str is already a mutable, unchilled string?
    Nothing: `String#+@` returns the receiver itself, with no copy. It only allocates when the receiver is frozen or chilled.

saying these in an interview costs you the question

  • +str always allocates a copy, even when str is already mutable
  • -str freezes the receiver in place
  • Two equal strings passed through -@ are still different objects
  • String.new and +"" both return a UTF-8 buffer
  • -"sms".upcase applies the minus before calling upcase