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 thatsandbeforeboth name, andequal?staystrue;s += "e"builds a new string and rebindss, soequal?turnsfalseandbeforekeeps the appended"abcd". - Row 2.
upcase!changeduin place and returneduitself; called on a string that is already upper case it returnsnil, so a chain such asstr.upcase!.strip!breaks when the first step has nothing to do (Not machine-checked here.). The non-bangupcasereturns a new object and leavesvas it was. - Row 3.
[]=,insert,sub!,replaceandclearall edit the same object;tis one string throughout the group. - Row 4.
"a".frozen?isfalse: in Ruby 4.0 a literal is chilled — mutable, but flagged so that-W:deprecatedwarns when it is changed — andlit << "!"works.+"a"gives a mutable copy,-"a"the frozen, deduplicated one, andString.newa 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;joinandchars/[]=/joinare the other two ways to build. - Row 6. The cost of mutability, paid once per hash: a
Stringkey is copied and frozen on the way in, soh.keys.firstis a different, frozen object and appending to the originalkeyafterwards 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¶
- Write
censor!(text, word)that replaces every occurrence ofwordintextwith asterisks in place (gsub!), andcensor(text, word)that returns a new string (gsub). Run both on"the secret is a secret", print the original after each, useequal?to show which one returned the caller's object, and callcensor!a second time — what does it return when there is nothing left to replace? - Build a 1,000-character string two ways,
s += "x"in a loop ands << "x"in a loop, and count how many timessstops beingequal?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.
See also¶
- A variable is a reference, not a box — the sharing that makes an in-place edit visible everywhere
freezeis shallow and raisesFrozenError— how to stop a string from changing, and what the magic comment does- Bang and question methods — row 2's
nil-when-unchanged rule acrosssort!,uniq!,strip!andsub! - What changed per Ruby version — where chilled literals and the frozen-literal comment entered the language
bytearrayis the mutable one ↗ — Python's one mutable string type, measuredstris notbytes↗ — the two Python types that one Ruby class covers- A string is bytes plus a label ↗ — the Ruby text library on what a
Stringholds - String literals are chilled, not frozen ↗ — row 4 in full
- String ↗ — the Ruby 4.0 docs, with every method's bang twin
- Text lives in the Ruby text library — a Ruby
Stringis bytes plus an encoding label - Twenty-three Ruby traps for a Python programmer — The twenty-three places where a Python reflex gives the wrong answer in Ruby
- Twenty-three Python traps for a Ruby programmer — The twenty-three places where a Ruby reflex gives the wrong answer in Python