Skip to content

Bang means dangerous, question mark means predicate

Level: 101 · a first-week reader who writes Python

One line: A trailing ! marks the more dangerous of two versions of a method — usually the one that changes its receiver, and several of those return nil when nothing changed — and a trailing ? marks a method that answers a yes/no question.

Ruby lets ? and ! end a method name, and the community uses them as signals. ? is the easy one: empty?, zero?, include?, frozen? answer a question and return true or false. ! is subtler. It does not mean "mutates" — <<, push, pop and delete all mutate without it — it means "the more dangerous of these two"; when a method comes in a pair, the bang version is the one that changes the receiver (sort!), raises instead of returning nil, or, for exit!, skips the clean-up handlers. Several bang methods on String and Array (strip!, uniq!, sub!, upcase!, flatten!, compact!) also return nil when they had nothing to do, so a chain like s.strip!.upcase works on one input and raises NoMethodError on another.

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

1. sort returns a new array:      a.sort                -> [1, 2, 3], a is still [3, 1, 2]
2. sort! changes the receiver:    a.sort!               -> [1, 2, 3], a is now [1, 2, 3], r.equal?(a) is true
3. nil when nothing changed:      [1, 2].uniq!          -> nil
                                  "x".strip!            -> nil
                                  "abc".sub!("z", "y")  -> nil
                                  "ABC".upcase!         -> nil
                                  [1, 2].flatten!       -> nil
                                  [1, 2].compact!       -> nil
   the new value when it did:     [1, 1].uniq!          -> [1]
                                  " x ".strip!          -> "x"
4. so chaining a bang breaks:     "abc".strip!.upcase   -> NoMethodError: undefined method 'upcase' for nil
   chain the non-bang versions:   " abc ".strip.upcase  -> "ABC"
5. ? methods answer a question:   [].empty? true, 0.zero? true, [1, 2].include?(2) true, "a".frozen? false, 1.even? false, nil.nil? true
6. ? and ! are part of the name:  def ready? -> :ready?, def go! -> :go!, "abc".respond_to?(:strip!) true
7. ! is not always mutation:      exit  -> stdout "exiting\nat_exit ran\n", status 0
                                  exit! -> stdout "exiting\n", status 1
8. mutation without a bang:       b << 3; b.push(4); b.pop -> 4, b is [1, 2, 3]
                                  (the bang marks the dangerous one of a pair; a method with no twin has no bang)

Reading the output

Rows 1 and 2 are the pair every reader meets first: sort returns a new array and leaves a alone; sort! sorts a in place and returns a itself (r.equal?(a) is true), so it chains. Row 3 is the trap: uniq!, strip!, sub!, upcase!, flatten! and compact! return nil when nothing changed, and the value only when something did. Row 4 shows the consequence — "abc".strip!.upcase raises NoMethodError: undefined method 'upcase' for nil — and the fix, which is to chain the non-bang versions.

Row 5 is the ? convention in action; row 6 shows the characters are simply part of the name (method(:ready?).name is :ready?). Row 7 is the case that proves ! is about danger, not mutation: two child Rubies each register an at_exit handler and print "exiting"; exit runs the handler and exits 0, exit! skips it — and its default status is 1. Row 8 is the other half of the rule: <<, push and pop change the array without any bang, because they have no gentler twin to be contrasted with.

Compared with Python

Python has no naming convention for danger; it has a return convention. A method that changes its receiver returns None — list.sort(), append, extend, reverse, clear — and the copying version is a separate function (sorted, reversed). Rows 1 to 4 line up with Ruby's exactly: sorted(a) copies, a.sort() mutates and returns None, and chaining it (a.sort().append(1)) fails with AttributeError on None the way Ruby's chain failed on nil. The difference is that Python's None comes back always, not only when nothing changed, so there is nothing to signal (row 3): str is immutable, so strip() and upper() always return a string. Row 5: predicates are is_/has_ names (isalpha, isdigit) or plain expressions (len(x) == 0, x in y), and row 6 shows def ready?(): does not parse. Row 7: sys.exit() runs atexit handlers, os._exit(0) skips them — the same pair, without the punctuation to warn you. Row 8: append returns None, pop returns the element; no rule tells you which.

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

1. sorted returns a new list:     sorted(a)             -> [1, 2, 3] a is still [3, 1, 2]
2. sort() changes the receiver:   a.sort()              -> None a is now [1, 2, 3]
3. nothing-changed returns:       [1, 2].sort()         -> None (None whether or not anything changed)
                                  'x'.strip()           -> 'x'
                                  'abc'.replace('z','y') -> 'abc'
                                  'ABC'.upper()         -> 'ABC' (str is immutable: always a string)
                                  [1, 2].reverse()      -> None
                                  [1, 2].clear()        -> None
   the new value when it did:     sorted([1, 1])        -> [1, 1] (a copy, so nothing to signal)
                                  ' x '.strip()         -> 'x'
4. so chaining sort() breaks:     a.sort().append(1)    -> AttributeError: 'NoneType' object has no attribute 'append'
   chain the copying versions:    ' abc '.strip().upper() -> 'ABC'
5. predicates are is_/has_ names: len([]) == 0 True 0 == 0 True 2 in [1, 2] True 'a'.isalpha() True 1 % 2 == 0 False None is None True
6. ? and ! do not parse:          def ready?(): -> SyntaxError ; def go!(): -> SyntaxError ; hasattr(str, 'strip') True
7. the dangerous exit:            sys.exit()  -> stdout 'exiting\natexit ran\n', status 0
                                  os._exit(0) -> stdout 'exiting\n', status 0
8. mutation returns None:         b.append(3) -> None ; b.pop() -> 4 ; b is [1, 2, 3]
                                  (no naming rule: append, extend, remove, reverse all return None)

Try it

  1. Write normalize!(s) that strips and downcases s in place using strip! and downcase! on separate lines and returns s. Show it on " MiXed " and on "clean". Then write the naive chained version s.strip!.downcase! and show what it does on "clean", on " MiXed " and on " mixed ".
  2. Write a Basket class with add(x) returning self (so basket.add(1).add(2) chains), add!(x) that raises when the basket already holds three items, and full?. Print a chain, full? before and after, and the class of the exception add! raises.
Solution to 1

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

# Kata: normalize a string in place without chaining bang methods, then show
# why the chained version is a trap.
def normalize!(s)
  s.strip!
  s.downcase!
  s
end

def normalize_chained!(s)
  s.strip!.downcase!
end

a = "  MiXed  "
puts "1. normalize!(\"  MiXed  \")           -> #{normalize!(a).inspect}, and a is now #{a.inspect}"
b = "clean"
puts "2. normalize!(\"clean\")               -> #{normalize!(b).inspect} (nothing changed, still returns the string)"
c = "clean"
begin
  normalize_chained!(c)
rescue NoMethodError => e
  puts "3. normalize_chained!(\"clean\")       -> #{e.class}: #{e.message}"
end
d = "  MiXed  "
puts "4. normalize_chained!(\"  MiXed  \")   -> #{normalize_chained!(d).inspect} (works only because strip! changed something)"
e = " mixed "
puts "5. normalize_chained!(\" mixed \")     -> #{normalize_chained!(e).inspect} (strip! changed it, downcase! did not, and e is #{e.inspect})"

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

1. normalize!("  MiXed  ")           -> "mixed", and a is now "mixed"
2. normalize!("clean")               -> "clean" (nothing changed, still returns the string)
3. normalize_chained!("clean")       -> NoMethodError: undefined method 'downcase!' for nil
4. normalize_chained!("  MiXed  ")   -> "mixed" (works only because strip! changed something)
5. normalize_chained!(" mixed ")     -> nil (strip! changed it, downcase! did not, and e is "mixed")

See also