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.
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:
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:
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:
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¶
- Raw strings, escapes and the literal prefixes — all twelve literal forms, run
- Meet the byte — what
b'A'is once it is au8 - Writing a number down — the Python
b'A'trap in full - Printing bytes — the way back out:
escape_ascii()prints a byte string in this literal's own syntax, where{:?}gave[65, 9, 255] - Six kinds of string — where
&[u8]sits among the promises - Match ergonomics — the other RFC this library reads line by line
- RFC 69 ↗ — two pages; read it, it is short
- rust#14646 ↗ — the tracking issue, titled RFC 23 because the RFC repo renumbered its files to PR numbers afterwards
- Byte and byte string literals ↗ — the Reference, which is what the RFC became
bytearrayis the mutable one ↗ — the language that did not add this literal. Python builds a byte buffer by callingbytearray(b"…"), and the reason is the same one that makesb"abc"here a&[u8; 3]: a literal lives in read-only memory, so an editable one would have to be copied first
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