Skip to content

RFC 69 — how Rust got b'A'

Level: 201 · working knowledge

One line: Four literal forms, one two-page proposal, and three questions its author left open — all three of which have since been answered, one of them by the compiler quietly changing what his example meant.

let byte  = b'A';                       // 65u8
let bytes = b"A\t\xFF";                 // [65, 9, 255]
let raw   = br"C:\temp";                // no escapes, still bytes
let hash  = br#"a "quoted" thing"#;     // and the # widens the delimiter

Those four spellings arrived together on 2014-07-02, two months after RFC 69 ↗ proposed the first two. This page is what the proposal argued, what it got wrong, and what it never guessed.


What it asked for

Simon Sapin filed it on 2014-05-05, from Mozilla Research — the same work that produced rust-url and Servo's CSS parser, which is why the motivation reads like a browser-engine bug list rather than a language wishlist:

the HTTP protocol was originally defined as Latin-1, but in practice different pieces of the same request or response can use different encodings

A str is UTF-8 and refuses to be anything else. A response header that is Latin-1 in the status line and UTF-8 in a cookie is not text yet — it is bytes you have to look at before you know. The RFC's other example is PDF: mostly ASCII, with UTF-16 strings and raw binary inside it. Both are cases where you need to match on a byte without first claiming the surrounding bytes are a string.

The proposal was three sentences of syntax. Prefix a character or string literal with b; restrict the unescaped body to U+0000–U+007F; ban \u and \U; and make \x mean one byte rather than one code point, so it reaches \xFF instead of stopping at \x7F. It asserted four equalities and promised one type.

The four equalities held; the type did not

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

RFC 69'S FOUR ASSERTED EQUALITIES
  b'A'    == 65u8         true
  b'\t'   == 9u8          true
  b'\xFF' == 0xFFu8       true
  b"A\t\xFF"              [65, 9, 255]

THE TYPE THE RFC PROMISED IS NOT THE TYPE THAT SHIPPED
  RFC   &'static [u8]     a slice: pointer AND length, carried at run time
  today &'static [u8; 3]  an array reference: the length is in the type
  a slice reference is exactly twice as wide   true
  ...and it still coerces, so the RFC's own signature accepts it   3 bytes

Q1  "Should there be raw byte string literals?"   ANSWERED YES
  br"..."    36 bytes  << /Title (FizzBuzz \(Part one\)) >>
  br#"..."#  16 bytes  a "quoted" thing

Q2  "Should control characters be disallowed?"    ANSWERED NO
  b"a<TAB>b"           [97, 9, 98]
  b"a\0b"              [97, 0, 98]

Q3  "Should bytes!() be removed?"                 ANSWERED YES
  b"A"                 [65]   the macro is gone; this replaced it

THE RFC'S EXAMPLE COMPILES AGAIN, AND NO LONGER MEANS WHAT IT SAID
            b'a' .. b'z'   b'a' ..= b'z'
  'a'       lower          lower
  'y'       lower          lower
  'z'       other          lower

b"…" is an array reference today, not the slice the RFC named. That is the one line of the proposal the language walked back, and it walked back in the reader's favour: &[u8; 3] is one pointer, &[u8] is a pointer and a length, so the array form is half the width and the length is a compile-time constant. It coerces to the promised type wherever a slice is wanted, which is why almost nobody notices — but only the array form knows its own length at compile time, so only the array form destructures irrefutably:

let [a, b, c] = b"abc";              // fine: the type says there are three
let s: &[u8] = b"abc";
let [x, y, z] = s;                   // E0005: refutable pattern in local binding

What it argued against

The RFC's Alternatives section is short and worth reading, because two of the three rejected designs were what Rust actually had:

the alternative what it looked like why it lost
status quo, numeric c @ 0x61 .. 0x7A correct, and nobody reading it knows those are a and z
status quo, cast match buffer[i] as char { c @ 'a' .. 'z' => … the RFC's own comment: // c is of the wrong type! — you wanted a u8 back and you have a char
a macro in patterns c @ byte!('a') .. byte!('z') needs a language change of its own, to allow macros in pattern position, and is longer than the thing it replaces
a different prefix a'A' for ASCII, or a u8 suffix bikeshed; b won for matching Python

The macro row is the one with history behind it. Rust had shipped bytes! in 0.7, ten months before — bytes!("A") gave you a byte vector — and it could not appear in a pattern, which is exactly where the RFC wanted bytes. The RFC replaced it rather than extending it, and its author deprecated the macro himself in rust#15017 ↗ two weeks after the literals landed.

Abridged — real rustc output for bytes_macro.rs
error: cannot find macro `bytes` in this scope
 --> bytes_macro.rs:2:18
  |
2 |     let _bytes = bytes!("A");
  |                  ^^^^^

The three questions it left open

The RFC ends with three questions and no answers. All three are settled now, and none of them by a later RFC — each was decided by an implementation.

"Should there be raw byte string literals?" — Yes, and faster than the question implies. rust#14880 ↗, the PR that implemented the RFC, added byte, byte string and raw byte string literals in one go; the 0.11.0 release notes announce all three. The RFC guessed the spelling wrong, and rustc will tell you so:

Abridged — real rustc output for rb_prefix.rs, second parse error dropped
error: prefix `rb` is unknown
 --> rb_prefix.rs:2:16
  |
2 |     let _pdf = rb"<< /Title (FizzBuzz) >>";
  |                ^^ unknown prefix
  |
  = note: prefixed identifiers and literals are reserved since Rust 2021
help: use `br` for a raw byte string
  |
2 -     let _pdf = rb"<< /Title (FizzBuzz) >>";
2 +     let _pdf = br"<< /Title (FizzBuzz) >>";
  |

b then r, reading outward: bytes, and raw. Python spells the same thing both ways and does not care.

"Should control characters be disallowed?" — No. A literal tab typed between the quotes is a tab, in every literal form, and \0 is an ordinary byte. The one place a control character is refused is c"…", and that refusal belongs to the C string type rather than to the syntax — a CStr ends at its NUL, so it cannot contain one. Which promise each type makes is six kinds of string.

"Should bytes!() be removed?" — Yes, before 1.0. It is not a deprecation you can still trip over; the macro simply is not there.

The example that changed meaning underneath it

The RFC's one worked snippet is a lowercase-letter test:

match buffer[i] {
    b'a' .. b'z' => { /* ... */ }
    c => { /* ... */ }
}

In May 2014 that matched a through z, because .. in a pattern was the inclusive range. Five months later Rust 0.12.0 moved inclusive pattern ranges to 0...4 "to be consistent with the exclusive range syntax for slicing", and the snippet stopped compiling — for nine years. ... was replaced by ..= in 1.26 ↗ and started warning in 1.37. Then Rust 1.80 stabilized exclusive_range_pattern, and .. came back to patterns meaning the opposite of what it meant when the RFC was written.

So the snippet compiles again, silently, and z is no longer a lowercase letter:

b'a' .. b'z' b'a' ..= b'z'
b'a' lower lower
b'y' lower lower
b'z' other lower

There is a lint for the shape — non_contiguous_range_endpoints, added alongside the stabilization — but it fires on a gap between arms, and a lone arm with a fallthrough has no gap to see. Reach for ..= in a byte range unless you have a reason not to; a byte range's endpoint is nearly always a character you mean to include.

The half the RFC could not have written

c"…" is the fourth prefix, stabilized in 1.77 ↗ — a decade later, for a problem RFC 69 did not have: a NUL-terminated literal to hand to C. That makes five prefixes and their raw combinations, and raw strings, escapes and the literal prefixes is the table of all of them.

Everything RFC 69 says about encodings is still the argument for reaching for b"…" — a byte you have not decoded yet is not a character, and pretending otherwise is where mojibake comes from ↗. The sibling encodings learning library ↗ is the RFC's motivation section written out at length: control characters ↗ is unresolved question 2 in full, and validation is a boundary ↗ is what the b prefix buys you — the right to postpone the check rather than fail it.

If you are coming from another language

Python. The RFC says outright that it was written to align with Python — "There is a precedent at least in Python, which has both Unicode and byte strings" — and the alignment is real at the level of the prefix table and breaks at the level of what a literal is.

Python Rust
b"A\t\xFF" a byte string literal b"A\t\xFF" — same spelling, same three bytes
rb"…" and br"…" raw bytes; Python accepts either order br"…" only — rb is an error, with a fix-it
b'A' is bytes of length 1 the singular is still a container b'A' is 65u8; the number, not a box round it
b'A'[0] == 65 how you get the number in Python b'A' == 65 — no indexing step
bytes([0x61, 0x7A]) building from numbers [0x61, 0x7A] is already [u8; 2]
for c in data: if b'a' <= c <= b'z': TypeError: '<=' not supported between instances of 'bytes' and 'int' — iterating bytes yields int, so you must write ord('a') <= c <= ord('z') matches!(c, b'a'..=b'z')

The trap is the third row and it produces a type error rather than a wrong answer, which is the good kind: Python's b'A' maps to Rust's b"A", and Rust's b'A' maps to Python's ord('A'). Writing a number down works that pair through.

The last row is the RFC's own argument, still unwon on the other side. Python 3 made iterating bytes yield int, so the moment you are doing the thing RFC 69 was written for — looking at one byte of a buffer — the literal you borrowed the prefix from stops being usable and you are back to ord('a') <= c <= ord('z'), or to 0x61 and a comment. That is the status quo row of the Alternatives table, in a language that has had the b prefix since Python 3.0.

What the RFC borrowed from Python is the prefix; what it did not borrow is Python 3's enforcement. Python raises TypeError: can't concat str to bytes at run time, on the line that mixes them. Rust's &str and &[u8] are simply different types, so the same mistake is a compile error, and the fix-it is the RFC's own syntax:

Abridged — real rustc output for as_bytes.rs, type notes dropped
error[E0308]: mismatched types
 --> as_bytes.rs:2:24
  |
2 |     let bytes: &[u8] = "abc";
  |                -----   ^^^^^ expected `&[u8]`, found `&str`
  |
help: consider adding a leading `b`
  |
2 |     let bytes: &[u8] = b"abc";
  |                        +

Same boundary, moved from the traceback to the build.

ABAP. The split already exists and predates both: STRING/C on the text side, XSTRING/X on the byte side, and the X literal X'C582' is b"\xC5\x82" written as data. Two differences worth carrying over. An X literal takes hex digits only, so there is no way to spell a byte by the character it stands for: X'41' is the letter A and nothing in the source says so. b'A' therefore has no ABAP counterpart at all, and the readability argument that motivated the whole RFC is one ABAP has never been in a position to make. And ABAP converts between STRING and XSTRING through explicit function modules (SCMS_STRING_TO_XSTRING) or a CODE_PAGE conversion object, which is Rust's .as_bytes() / String::from_utf8 pair with the same asymmetry: one direction always works, the other can fail.

See also

Po polsku

RFC 69 to dwustronicowa propozycja z 2014 roku, która dodała do Rusta cztery zapisy: b'A' (jeden bajt), b"…" (łańcuch bajtów), oraz surowe warianty br"…" i br#"…"#. Powód był praktyczny, nie estetyczny — autor pisał silnik przeglądarki, a nagłówek HTTP potrafi mieć różne kodowania w różnych częściach jednej odpowiedzi, więc bajty trzeba obejrzeć, zanim ogłosi się, że to tekst. Dokładnie tu leży różnica między &str a &[u8]: &str obiecuje poprawny UTF-8, a &[u8] nie obiecuje niczego — i o to chodzi.

Dla polskiego czytelnika najważniejszy jest wiersz o \x. W str sekwencja \x kończy się na \x7F, czyli na ASCII, a w b"…" sięga \xFF — dlatego ł w łańcuchu bajtowym zapisuje się jako b"\xC5\x82" (te same dwa bajty, którymi UTF-8 koduje tę literę), a wpisanie b'ł' wprost jest błędem kompilacji: non-ASCII character in byte literal.

Jedna pułapka jest nowa i warto ją znać, bo dotyczy kodu pisanego dziś. Przykład z samego RFC — b'a' .. b'z' — w 2014 roku oznaczał zakres od a do z włącznie, potem przez dziewięć lat w ogóle się nie kompilował, a od Rusta 1.80 kompiluje się znowu i oznacza od a do y. Zakres domknięty zapisuje się teraz ..=, i to jego zwykle chcesz.

Szukaj po polsku: literał bajtowy · łańcuch bajtów · zakres domknięty w dopasowaniu wzorca · rust b"" byte string literal · rust ..= inclusive range pattern