Numbers become text with format, to_s(16) and %¶
Level: 201 · anyone printing a price, a hex dump or a table
One line: format speaks C's mini-language — with ..-prefixed two's-complement negatives and no thousands flag — to_s(base) and to_i(base) go between bases, Integer() parses strictly where to_i never fails, and Float#to_s prints the shortest string that reads back as the same Float; Python's f-strings add a , flag, keep the minus sign on a negative, and switch to exponent form one digit later.
format(spec, *args) — also sprintf, and spec % args on a String — is where Ruby turns a number into text with a width, a precision or a base: %.2f, %08.3f, %e, %x, %b, %o, %+d, %-5d, and %#x for a prefix. Integer#to_s(base) writes any base from 2 to 36 without a prefix, and String#to_i(base), hex and oct read it back — never failing, so junk becomes 0 — while Integer(str) and Float(str) raise on anything that is not a number and read 0x, 0b, 0o and a bare leading 0 as prefixes. A Float prints itself as the shortest decimal that reads back as the same Float — 0.30000000000000004, 1.0e+20 — and formatting is how you choose the digits instead. Two rounding facts come with all this: format("%.2f", 2.675) is "2.68" where Python prints 2.67, and Integer#round(-2) rounds a half away from zero, so 1250.round(-2) is 1300.
Verified output of number_formatting_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. format("%.2f", 3.14159) -> "3.14" fixed decimals; "%.2f" % 3.14159 is the String#% spelling
2. format("%08.3f", 3.14159) -> "0003.142" width 8, zero-filled, 3 decimals
3. format("%e", 123456.789) -> "1.234568e+05" scientific; %.3e of 0.000123456 is "1.235e-04", %g picks a form: "1.23457e+06"
4. format("%x %b %o", 255, 255, 255) -> "ff 11111111 377" bases without a prefix; %#x %#b %#o give "0xff 0b11111111 0377"
5. format("%+d|%05d|%-5d|", 5, 42, 42) -> "+5|00042|42 |" a sign, zero fill, left alignment
6. format("%08b", -5) -> "..111011" a negative in a base is two's complement behind a .. prefix; %x of -255 is "..f01"
7. format("%.2f", 2.675) -> "2.68" not 2.67: 2.665 gives 2.66 and 2.685 gives 2.68, half to even on the decimal digits
8. 1234567.to_s.gsub(/\B(?=(\d{3})+\z)/, ",") -> "1,234,567" no thousands flag in format, so an idiom; %,d is ArgumentError: malformed format string - %,
9. 255.to_s(16), 255.to_s(2), 255.to_s(36) -> "ff", "11111111", "73" to_s takes a base from 2 to 36
10. "ff".hex, "ff".to_i(16), "0x1f".to_i(0) -> 255, 255, 31 back from a base; base 0 reads the prefix; "zz".hex is 0, because to_i never fails
11. Integer("0x1f", 16), Integer("0b101"), Integer("1_000") -> 31, 5, 1000 Integer() reads prefixes and underscores, and raises on junk
12. Integer("08") -> ArgumentError: invalid value for Integer(): "08" a leading 0 means octal, where 8 is not a digit; "08".to_i is 8
13. (0.1 + 0.2).to_s, 1e20.to_s -> "0.30000000000000004", "1.0e+20" to_s is the shortest string that reads back as the same Float; 100.0.to_s is "100.0", never without the .0
14. 999999999999999.9.to_s, 1e15.to_s -> "999999999999999.9", "1.0e+15" exponent form once the integer part has 16 digits; and below 0.0001: 1e-4.to_s is "0.0001", 1e-5.to_s is "1.0e-05"
15. 1234.5678.round(2), .floor(2), .round(-2) -> 1234.57, 1234.56, 1200 round(-2) returns an Integer; 1250.round(-2) is 1300, half away from zero
16. 3.1.round(2).to_s, format("%.2f", 3.1) -> "3.1", "3.10" round drops trailing zeros, format keeps them: money wants format
Reading the output¶
Rows 1–7 are format. The directives are C's: a precision after the dot, a width before it, 0 to fill with zeros, + for an explicit sign, - to left-align, e and g for the scientific and the shorter form, x, b and o for bases, and # for a prefix — where Ruby's octal prefix is a bare 0, not Python's 0o. Row 6 is Ruby's own convention for a negative number in a base: %08b of -5 is ..111011, the two's-complement bit pattern with .. standing for the infinite run of ones to its left, and %x of -255 is ..f01. Row 7 is a rounding surprise: the Float 2.675 is stored as a value just below 2.675, and rounding that exact value — what Python does — gives 2.67, but Ruby's format gives 2.68; 2.665 gives 2.66 and 2.685 gives 2.68, which is the pattern of rounding the decimal digits 2.675 half to even, the shortest form rather than the exact value (Not machine-checked here.). Row 8 is the missing feature: format has no thousands separator, so Ruby code inserts commas with a regex — \B(?=(\d{3})+\z) marks every position that is followed by a multiple of three digits — and %,d is a malformed format string.
Rows 9–12 are bases and parsing. to_s(16), to_s(2) and to_s(36) write a number in any base; hex, to_i(16) and to_i(0) — base 0 reads the prefix — bring it back. The to_i family never raises: "zz".hex is 0, and "08".to_i is 8. Integer() is the strict parser: it understands prefixes and underscores, and it raises on junk — including "08", because a leading 0 makes it read octal, where 8 is not a digit. Rows 13 and 14 are Float#to_s: the shortest round-trip form, always with a decimal point or an exponent, switching to 1.0e+15 form once the integer part reaches 16 digits and to 1.0e-05 form below 0.0001. Rows 15 and 16 are rounding against formatting: round(2) and floor(2) return Floats, round(-2) returns an Integer and rounds a half away from zero (1250 to 1300), and round drops trailing zeros — 3.1.round(2) prints as 3.1 — where format("%.2f", 3.1) keeps them, which is why money is formatted, not rounded.
Compared with Python¶
The mini-language is the same one — format(x, ".2f"), f"{x:08.3f}", % on a string — with a few additions and two different conventions. Python has a thousands flag built in, f"{n:,}" and f"{n:_}", which is row 8's regex in one character. hex, bin and oct always carry their prefix (format(255, "x") is the bare form), and #o gives 0o377. A negative number keeps its minus sign in every base — f"{-5:08b}" is -0000101 — where Ruby shows two's complement. int(s, base) is strict like Integer(): junk raises, a prefix needs base 0 or the matching base, and int("08") is simply 8, because a leading zero means nothing in base 10 (int("017", 0) raises instead). Row 7 is the rounding difference: format(2.675, ".2f") is 2.67, from the exact binary value. Row 14 is the threshold: str(1e15) is positional and str(1e16) is the first exponent form, one digit later than Ruby, and the exponent form has no .0 (1e+20). Row 15 is round(1250, -2) giving 1200 — banker's rounding again — and round(x, -2) on a float staying a float.
Verified output of number_formatting_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. format(3.14159, '.2f') -> '3.14' fixed decimals; f'{3.14159:.2f}' and '%.2f' % 3.14159 are the other spellings
2. f'{3.14159:08.3f}' -> '0003.142' width 8, zero-filled, 3 decimals
3. f'{123456.789:e}' -> '1.234568e+05' scientific; f'{0.000123456:.3e}' is '1.235e-04', g picks a form: '1.23457e+06'
4. f'{255:x} {255:b} {255:o}' -> 'ff 11111111 377' bases without a prefix; #x #b #o give '0xff 0b11111111 0o377'
5. f'{5:+d}|{42:05d}|{42:<5d}|' -> '+5|00042|42 |' a sign, zero fill, left alignment
6. f'{-5:08b}' -> '-0000101' a negative keeps its minus sign, no two's complement; f'{-255:x}' is '-ff'
7. format(2.675, '.2f') -> '2.67' the exact binary value is rounded: 2.665 gives 2.67 and 2.685 gives 2.69
8. f'{1234567:,}' -> '1,234,567' a thousands flag is built in; f'{1234567:_}' is '1_234_567', f'{1234567.891:,.2f}' is '1,234,567.89'
9. hex(255), bin(255), format(255, 'x') -> '0xff', '0b11111111', 'ff' hex/bin/oct carry a prefix; no to_s(36), but int('73', 36) reads it: 255
10. int('ff', 16), int('0x1f', 16), int('0x1f', 0) -> 255, 31, 31 back from a base; base 0 reads the prefix; int('zz', 16) is ValueError: int() always raises on junk
11. int('0b101', 0), int('1_000'), int('017', 8) -> 5, 1000, 15 a prefix needs base 0 or the matching base; underscores are fine
12. int('08') -> 8 a leading 0 is just a digit in base 10; int('017', 0) is ValueError
13. str(0.1 + 0.2), str(1e20) -> '0.30000000000000004', '1e+20' str is the shortest string that reads back as the same float; no .0 in the exponent form
14. str(999999999999999.9), str(1e15) -> '999999999999999.9', '1000000000000000.0' positional up to 16 integer digits; exponent form from 17: str(1e16) is '1e+16'; small: str(1e-5) is '1e-05'
15. round(1234.5678, 2), round(1234.5678, -2) -> 1234.57, 1200.0 round(x, -2) on a float stays a float; round(1250, -2) is 1200, half to even
16. str(round(3.1, 2)), f'{3.1:.2f}' -> '3.1', '3.10' round drops trailing zeros, format keeps them: money wants format, or Decimal
The Python library's The format mini-language ↗ works through the spec character by character, and bin() is not the bits ↗ is the page on bin(-5) printing a sign rather than a bit pattern — row 6 is Ruby's answer to the same question.
Try it¶
- Print a price table — item names left-aligned, amounts right-aligned in a 14-character column with thousands separators and two decimals — for
laptop 1299.99,mouse 24.5,desk 1234567.891andrefund -1234.5, then a total line.format("%.2f", x)gives the decimals; onegsubgives the commas. - Write
to_base(n, base)that builds whatn.to_s(base)returns fromn.abs.digits(base)and the digit characters0-9a-z, andfrom_base(s, base)that reverses it without callingto_i; check both againstto_sandto_ifor255,1_000_000and2 ** 70in bases 2, 16 and 36.
Solution to 1
number_formatting_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Kata: a price table — names left-aligned, amounts right-aligned with
# thousands separators and two decimals — from format and one gsub.
def money(x) = format("%.2f", x).gsub(/\B(?=(\d{3})+\.)/, ",")
items = { "laptop" => 1299.99, "mouse" => 24.5, "desk" => 1234567.891, "refund" => -1234.5 }
items.each { |name, price| puts format("%-8s %14s", name, money(price)) }
puts format("%-8s %14s", "total", money(items.values.sum))
Verified output of number_formatting_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
See also¶
- Integers never overflow:
2 ** 200is just an Integer —to_s(2),digitsandbit_length: the number side of the bases 0.1 + 0.2 != 0.3, and2.5.roundis3— why2.675is stored below 2.675, and howFloat#roundrounds it1/3randBigDecimal("0.1")are exact where Float is not —BigDecimal#to_s("F")andto_s("3F"): formatting a decimal that has no binary error- String formatting and interpolation —
%<name>d,%{name},ljust,rjust,centerand heredocs: the string side offormat - The format mini-language ↗ — the Python library's page on the spec
bin()is not the bits ↗ — the Python library on a negative number in binaryto_inever fails;Integer()does ↗ — the Ruby text library's page on rows 10–12ljustand%-8spad by code points, not columns ↗ — what a width counts when the text is not ASCIIRandom.new(42)gives the same numbers every run — A seededRandomis a function of its seed