Skip to content

Strings are mutable; << keeps the object

Level: 101 · a Python programmer who has never had to ask whether a string changed underneath them

One line: A Ruby String is a mutable object — <<, upcase!, []=, insert, sub! and replace change it in place and keep its identity, while +, upcase and sub return a new string — and a literal is chilled rather than frozen in Ruby 4.0; a Python str cannot change at all, so += always rebinds and the mutable routes are bytearray, io.StringIO and a list joined at the end.

Ruby's string is a buffer with methods, and the buffer is the object. Every name that refers to it sees an in-place change, which is exactly the reference semantics of the previous page applied to text, and it is why String ships each operation twice: upcase returns a changed copy and leaves the receiver alone; upcase! changes the receiver and returns it — or nil when there was nothing to change, which is the trap in every chain of bang methods. << appends without allocating a new object, which is what makes building a string in a loop cheap (Not machine-checked here.), and it is also why Ruby has freeze, # frozen_string_literal: true and the "chilled" literal at all: a string that anyone may change is a string that needs protecting. The program prints six numbered groups, and the Python twin prints the same rows for an immutable str.

Verified output of strings_are_mutable_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. << mutates in place; += makes a new string
   s = "abc".dup; before = s; s << "d"; s           "abcd"
   s.equal?(before)                                 true
   s += "e"; s                                      "abcde"
   s.equal?(before)                                 false
   before                                           "abcd"
2. bang methods change the receiver; the others return a copy
   u = "abc".dup; u.upcase!; u                      "ABC"
   u.upcase! returned u itself                      true
   "ABC".dup.upcase!  (nothing to change)           nil
   v = "abc"; w = v.upcase; v                       "abc"
   w.equal?(v)                                      false
3. editing in place
   t = "abc".dup; t[0] = "X"; t                     "Xbc"
   t.insert(1, "-")                                 "X-bc"
   t.sub!("b", "B")                                 "X-Bc"
   t.replace("new")                                 "new"
   t.clear                                          ""
4. a literal is chilled, not frozen
   "a".frozen?                                      false
   lit = "lit"; lit << "!"; lit                     "lit!"
   (+"a").frozen?  (a mutable copy on demand)       false
   (-"a").frozen?  (the deduplicated one)           true
   String.new("a").frozen?                          false
5. building a string
   buf = +""; buf << "a" << "b"                     "ab"
   xs = "x".dup; 5.times { xs << "x" }              "xxxxxx"
   %w[a b c].join                                   "abc"
   chars = "abc".chars; chars[0] = "X"; join        "Xbc"
6. what mutability costs: a Hash copies and freezes a String key
   key = "key".dup; h = { key => 1 }                {"key" => 1}
   h.keys.first.frozen?                             true
   h.keys.first.equal?(key)                         false
   key << "!"; h["key"]  (the copy is untouched)    1

Reading the output

  • Row 1. s << "d" changes the string that s and before both name, and equal? stays true; s += "e" builds a new string and rebinds s, so equal? turns false and before keeps the appended "abcd".
  • Row 2. upcase! changed u in place and returned u itself; called on a string that is already upper case it returns nil, so a chain such as str.upcase!.strip! breaks when the first step has nothing to do (Not machine-checked here.). The non-bang upcase returns a new object and leaves v as it was.
  • Row 3. []=, insert, sub!, replace and clear all edit the same object; t is one string throughout the group.
  • Row 4. "a".frozen? is false: in Ruby 4.0 a literal is chilled — mutable, but flagged so that -W:deprecated warns when it is changed — and lit << "!" works. +"a" gives a mutable copy, -"a" the frozen, deduplicated one, and String.new a fresh buffer. The Ruby text library's literals are chilled ↗ page runs the warning and the magic comment five ways.
  • Row 5. << returns the receiver, so appends chain; a loop of << grows one buffer; join and chars/[]=/join are the other two ways to build.
  • Row 6. The cost of mutability, paid once per hash: a String key is copied and frozen on the way in, so h.keys.first is a different, frozen object and appending to the original key afterwards does not corrupt the lookup.

Compared with Python

Python's str is immutable, and every row is the mirror image. += rebinds — there is no __iadd__ on str to do anything else — so before still reads 'abc' after both appends (row 1). No method ends in ! because none of them can change the receiver; upper() returns a copy every time (row 2). Item assignment is a TypeError, and each edit of row 3 is a rebinding to a new string built from slices. A literal is immutable like every other str, and sys.intern is the nearest thing to -"a" — one shared object per spelling — while there is nothing to correspond to +"a" (row 4). When you need a buffer, Python hands you a different type: io.StringIO, bytearray — the Python library's page on it ↗ — or a list of characters joined at the end (row 5). And a dict needs no copying trick, because a key that cannot change cannot break the table: the key in d is the very object, and bytearray, which can change, is refused as a key outright (row 6).

Verified output of strings_are_mutable_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. no <<; += makes a new string
   s = "abc"; before = s; s += "d"; s               'abcd'
   s is before                                      False
   s += "e"; s                                      'abcde'
   s is before                                      False
   before                                           'abc'
2. every str method returns a copy; none ends in !
   v = "abc"; w = v.upper(); v                      'abc'
   w.upper() is w                                   False
   [m for m in dir(str) if m.endswith("!")]         []
   w                                                'ABC'
   w is v                                           False
3. no editing in place: every step rebinds
   t = "abc"; t[0] = "X"                            TypeError
   t = "X" + t[1:]                                  'Xbc'
   t = t[:1] + "-" + t[1:]                          'X-bc'
   t = t.replace("b", "B")                          'X-Bc'
   t = "new"                                        'new'
4. a literal is immutable, like every str
   type("a").__name__                               str
   lit = "lit"; lit += "!"; lit  (a new object)     'lit!'
   hasattr(str, "__iadd__")  (so += rebinds)        False
   sys.intern("a") is sys.intern("a")  (like -"a")  True
   hasattr(str, "__setitem__")                      False
5. building a string: the mutable routes
   buf = io.StringIO(); write("a"); write("b")      'ab'
   ba = bytearray(b"x"); 5 times ba += b"x"         'xxxxxx'
   "".join(["a", "b", "c"])                         'abc'
   chars = list("abc"); chars[0] = "X"; join        'Xbc'
6. what immutability buys: a str key needs no copying
   key = "key"; d = {key: 1}                        {'key': 1}
   next(iter(d)) is key  (the very object)          True
   {bytearray(b'key'): 1}  (a mutable key)          TypeError
   key += "!"; d["key"]  (key was rebound)          1

The other half of the comparison is what the two types are: Python separates text (str) from bytes (bytes), where a Ruby string is bytes plus an encoding label — the Python library's str is not bytes ↗ and the Ruby text library's a string is bytes plus a label ↗ measure that pair.

Try it

  1. Write censor!(text, word) that replaces every occurrence of word in text with asterisks in place (gsub!), and censor(text, word) that returns a new string (gsub). Run both on "the secret is a secret", print the original after each, use equal? to show which one returned the caller's object, and call censor! a second time — what does it return when there is nothing left to replace?
  2. Build a 1,000-character string two ways, s += "x" in a loop and s << "x" in a loop, and count how many times s stops being equal? to its previous value in each loop. Which loop rebinds on every step, and why does that matter for a string built from a million pieces?
Solution to 1

strings_are_mutable_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: censor! edits the caller's string in place with gsub!; censor returns
# a new string with gsub and leaves the original alone. equal? tells them
# apart, and the second censor! returns nil because nothing was left to change.

def censor!(text, word) = text.gsub!(word, "*" * word.length)
def censor(text, word) = text.gsub(word, "*" * word.length)

original = "the secret is a secret".dup
copy = censor(original, "secret")
puts "   censor returns            #{copy.inspect}"
puts "   original afterwards       #{original.inspect}"
puts "   copy.equal?(original)     #{copy.equal?(original)}"

result = censor!(original, "secret")
puts "   censor! returns           #{result.inspect}"
puts "   original afterwards       #{original.inspect}"
puts "   result.equal?(original)   #{result.equal?(original)}"
puts "   censor! a second time     #{censor!(original, "secret").inspect}"

Verified output of strings_are_mutable_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

   censor returns            "the ****** is a ******"
   original afterwards       "the secret is a secret"
   copy.equal?(original)     false
   censor! returns           "the ****** is a ******"
   original afterwards       "the ****** is a ******"
   result.equal?(original)   true
   censor! a second time     nil

See also