D24 · Every Built-in Method: String, Runes, StringBuffer & RegExp
By the end of this lesson you will know every public member of String, Runes, RuneIterator, StringBuffer, StringSink, Pattern, Match, RegExp and RegExpMatch in the Dart 3.11 SDK (94 members). For each one you will see what it does in plain words, whether it changes anything or returns something new, what it costs and why, what it can throw, and a real example with its real output. You will understand UTF-16 code units, surrogate pairs and runes, and you will be able to explain, with a memory picture, why s += t in a loop is slow and why StringBuffer is fast.
Related lessons: D04 · Strings (the first look at strings, interpolation and escapes) and D22 · Mutable vs Immutable (why strings never change in place, see its section 9). Next lessons in this series cover numbers, iterables, lists, sets, maps and more.
verify/d24.dart). The badge says what happens: read-only nothing is created or changed, new String / new object a different object comes back and the receiver is untouched, view a live window onto the same data, mutates the receiver itself changes (only StringBuffer and RuneIterator can do that: a String never changes).
1. What a String is in memory
String is a sentence engraved on a stone tablet. The tablet has a number on top (how many letters), and the letters are carved in a row. Nobody can erase one letter. If you want a different sentence you carve a NEW tablet, copying whatever you still need from the old one. The old tablet stays in the storeroom until the garbage collector (the cleaner who throws away tablets nobody points to) removes it.Some words, defined once:
- Code unit: one 16-bit number (0 to 65535) in the string's storage. Dart strings are UTF-16: the text is stored as a row of code units.
- Object: a package of data on the heap (the big shared storeroom). A String is an object with a header, a
lengthand the code units. - Immutable: cannot be changed after it is made. Every method on
Stringthat seems to "change" it returns a new String (or, when nothing would change, the same one). - Literal: text you type in the program, like
'hi'. Equal literals are stored once.
Watch what + and += really do. The picture is the Dart VM's: a stack slot holds an arrow to a String object on the heap.
Two strings can have the same letters and still be two different objects. == asks "same letters?", identical asks "same object?".
identical on two equal strings is true there.s[0] = 'D'. Dart reports a compile error (the []= operator does not exist on String). Use replaceRange, substring + concatenation, or a StringBuffer to build the new text.2. Code units, runes and graphemes
length,[],codeUnitAt,substringand every index in Dart count code units.- A lead surrogate is a unit in
0xD800..0xDBFF; a trail surrogate is in0xDC00..0xDFFF. A lead followed by a trail encodes one code point above 0xFFFF:0x10000 + ((lead − 0xD800) << 10) + (trail − 0xDC00). runesdecodes the pairs, so each emoji is ONE number. Thecharacterspackage (package:characters, shipped with Flutter) goes one level higher and walks graphemes; the core library does not have it.
Type any text (up to 16 UTF-16 units; paste an emoji if you like) and watch the decoder pair up surrogates.
And now the "one character on screen" problem. An accent can be a separate rune, and a family emoji is several people joined by a zero-width joiner (ZWJ, code unit 0x200D):
| Text | Looks like | length (code units) | runes.length | Graphemes |
|---|---|---|---|---|
'a' | 1 letter | 1 | 1 | 1 |
'é' (U+00E9) | 1 letter | 1 | 1 | 1 |
'é' | 1 letter | 2 | 2 | 1 |
'😀' | 1 emoji | 2 | 1 | 1 |
| family emoji (3 people + 2 ZWJ) | 1 emoji | 8 | 5 | 1 |
s.length is NOT "how many characters the user sees". For user-visible length, truncation or reversal of user text use package:characters. runes fixes surrogate pairs only, not accents or ZWJ sequences.length count code units. runes counts code points. Graphemes need the characters package.3. Create, read and inspect
Create: the three String constructors. Read: indexing, length, the two views (codeUnits, runes). Inherited from Object: the members every object has that String uses or overrides. Time costs are for a string of n code units.
Input size → what is feasible: all of these are O(1) except building (fromCharCodes is O(n)) and runes.length (O(n)); n = 107 units is fine.
First, how [] and codeUnitAt read a unit. Type your own text and index (text|index, text 1 to 16 units, index −20 to 20).
Now codeUnits. It does not copy the text: it is a thin read-only wrapper that asks the string for every element.
Building a String from numbers with String.fromCharCodes(codes, start, end). Numbers up to 0xFFFF become one code unit, bigger ones (code points) become a surrogate pair. Type codes|start|end (1 to 8 decimal numbers separated by commas, start and end are positions in that list):
Cards: create
Cards: read and inspect
Cards: inherited from Object
4. Compare and search
compareTo, == and hashCode compare; contains, startsWith, endsWith, indexOf, lastIndexOf, allMatches, matchAsPrefix search. A Pattern argument accepts a String or a RegExp. Input size → what is feasible: the String-pattern search is a plain scan, O(n·m) worst case, so n = 106 text and m ≤ 100 pattern (108 steps) is fine; for a long pattern and a long text use a smarter algorithm (KMP, question q24).
Dictionary order, step by step (type first|second, each 1 to 12 units):
A sliding search. Type text|pattern|start (text 1 to 20 units, pattern 0 to 6, start 0 to 25):
startsWith(pattern, index) and endsWith only look at ONE alignment, so they cost O(m), not O(n·m):
lastIndexOf is the same scan from the right:
allMatches never reports overlapping matches. Type text|pattern (text 1 to 16, pattern 1 to 4 units):
'aaaa'.allMatches style search skips overlaps: 'aa' in 'aaaaa' is found 2 times (at 0 and 2), not 4. To count overlapping matches use a loop with indexOf(p, pos + 1) (question q14) or a look-ahead RegExp (?=aa).Cards: compare
Cards: search
5. Slice, clean, replace and split
substring), trim the selvage (trim), swap a button (replace), cut at every seam (split) or add padding (pad) the tailor always cuts and sews a NEW garment and leaves the bolt untouched.Every member here returns a new String (or a new List of Strings for split), except when nothing changes: then the VM may hand back the same object (trim of a clean string, replaceAll with no match, substring(0)). Input size → what is feasible: all are O(n) in the string size (replaceAll with a String pattern O(n·m)); n = 107 units is fine, but do NOT call them inside a loop over the same long string (that turns O(n) into O(n²)).
split by a String separator. Type text|separator (text 0 to 14 units, separator 0 to 3 units; an empty separator splits between code units):
replaceAll scans left to right and never re-scans replaced text. Type text|from|to (text 1 to 14, from 0 to 4, to 0 to 4 units):
trim only walks while it sees whitespace. Type any text; write invisible characters as \t, \n or (text 1 to 16 units after decoding):
padLeft and padRight write the padding once per missing position. Type text|width|padding (text 0 to 6 units, width 0 to 12, padding 0 to 3 units):
splitMapJoin walks the text as alternating gaps and matches, converts each piece and joins them:
replaceAll does NOT understand $1 or $&: the replacement is used literally. If the replacement depends on the match, call replaceAllMapped.padLeft(width, padding) repeats padding once for every missing position. With a multi-unit padding the result is longer than width: 'x'.padLeft(4, 'ab') is 'abababx'.Cards: slice, clean, replace, split
6. Runes and RuneIterator
Runes is a pair of glasses that makes every two-stamp emoji look like ONE letter. A RuneIterator is a finger you move along the text: moveNext moves it forward by one real letter, movePrevious backward, and it always jumps over both stamps of a pair, so it can never stand between them.Runes is an Iterable<int>, so it also has every Iterable member (length, first, map, toList and so on: lesson D25). Its own members are only the constructor, string, iterator and the fast last. Most of the power sits in RuneIterator, which lets you go forward AND backward.
Walk a text rune by rune, then back. Type any text (1 to 12 UTF-16 units):
The iterator refuses to start inside a surrogate pair:
How a code point above 0xFFFF becomes two code units (String.fromCharCode). Type a code point in hex (0 to 110000):
Cards: Runes and RuneIterator
7. s += t versus StringBuffer
s += t): every time you add a word you carve a brand-new tablet that repeats the whole letter so far plus the new word, then throw the old tablet away. After 1000 words you have carved 1000 tablets, copying 1 + 2 + 3 + ... + 1000 words in total. Method 2 (StringBuffer): you write the words on a notepad (cheap, you never rewrite old words) and only at the very end you carve ONE tablet from the notepad with toString().The exact counting. Let each piece be one code unit and let the loop run n times. Iteration i builds a string of length i, which means copying the i − 1 old units and writing 1 new unit: i writes. Total writes = 1 + 2 + ... + n = n(n + 1)/2 = Θ(n²). A StringBuffer keeps the pieces in a list and copies them once in toString(): about n writes (a few more if the VM compacts small parts: still Θ(n)).
| n | s += t: n(n+1)/2 unit writes | StringBuffer: ≈ n unit writes | ratio |
|---|---|---|---|
| 10 | 55 | 10 | 5.5× |
| 100 | 5,050 | 100 | 50.5× |
| 1,000 | 500,500 | 1,000 | 500.5× |
| 10,000 | 50,005,000 | 10,000 | 5,000.5× |
| 100,000 | 5,000,050,000 | 100,000 | 50,000.5× |
First the memory picture of the loop s += 'x'. Type n (1 to 10) and watch a new String object appear every iteration while the old one becomes garbage:
Now count the work for both ways. Type n (1 to 60); the bars show the running total of unit writes:
How does StringBuffer avoid the copying? Here is the real Dart VM implementation (from string_buffer_patch.dart, Dart 3.11) on a small example. Pieces from write are added to a list of parts; single characters from writeCharCode go to a 64-unit scratch array; toString() turns the scratch array into a part and joins all parts in one allocation.
writeAll writes a separator BETWEEN elements, never before the first or after the last. Type items|separator (0 to 6 comma-separated items of 0 to 4 units, separator 0 to 3 units):
A real measurement
The same loop, 3 ways, timed with Stopwatch (best of 5 runs, Dart 3.11.5 on macOS arm64, dart run; verify/d24.dart repeats the experiment on every run and prints its own numbers). Times are the author's measurement, not a promise: your machine will differ but the shape will not.
| n pieces of one unit | s += 'x' | StringBuffer.write | List.join() | measured ratio (+= / StringBuffer) |
|---|---|---|---|---|
| 10,000 | 2.6 ms | 0.10 ms | 0.05 ms | about 26× (20× to 26× over my runs) |
| 100,000 | 212 ms | 0.9 ms | 0.9 ms | about 240× (238× to 252× over my runs) |
Multiplying n by 10 multiplied the += time by about 82 (quadratic: ideal 100) and the StringBuffer time by about 9 (linear: ideal 10). Those are exactly the shapes the counting predicts.
+ with ropes (a tree that remembers "left part + right part" without copying until someone reads it). There s += t in a loop can be fast, and Dart's web StringBuffer is itself just _contents = _contents + str. Do not rely on either behaviour: write the loop with a StringBuffer (or join) and it is fast on every platform.+ fine? A few fixed pieces: a + ', ' + b, or better interpolation '$a, $b' (the VM joins all the parts of one interpolation in a single allocation). The danger is only a loop that keeps growing one string. Alternatives to += in a loop: StringBuffer.write/writeAll/writeln, list.join(sep) (collect pieces, join once), String.fromCharCodes(units) (when you build from numbers) and 'ab' * n (repeat).Cards: StringBuffer and StringSink
8. Pattern, Match and RegExp
Pattern is a "thing that can look for itself in a text": a plain String looks for exactly its letters, a RegExp is a stencil with stretchy holes ("one or more digits", "any letter", "this OR that"). A Match is the finding: where it starts and ends, and what each bracketed part of the stencil caught (capture groups, numbered from 1 by the position of their opening bracket; group 0 is the whole match).A RegExp uses JavaScript-style regular-expression syntax. Its four flags are multiLine (^/$ at every line), caseSensitive, unicode (work on code points) and dotAll (dot also matches line breaks). Build a RegExp once and reuse it: compiling is the expensive part. A badly written pattern can backtrack exponentially, so never run an untrusted pattern on untrusted text. Input size → what is feasible: a simple pattern scans in O(n): n = 107 is fine.
All matches of a pattern, one by one. Type pattern ~ text (separate with a space, a tilde and a space; text 1 to 24 units; the animation uses the browser's identical regex syntax):
Capture groups and named groups on a date:
The four flags, each with a before and after:
Greedy versus lazy. Type a text (1 to 20 units, no line breaks) that contains < and >:
RegExp does not match the whole string unless you anchor it: RegExp(r'\d+').hasMatch('12a') is true (it found 12). Use ^\d+$ for "all digits". Always write patterns as raw strings r'...' so the backslash reaches the engine.RegExp.escape, otherwise a dot, a plus or a bracket in it changes the pattern (question q29).RegExp and RegExpMatch are marked @Deprecated.implement in Dart 3.11: you may keep using them as always but you must not write your own class that implements them (they will become final). If you need a custom pattern, implement Pattern (and Match) instead.Cards: Pattern and Match
Cards: RegExp and RegExpMatch
9. Mutable or immutable?
This is the String half of D22 · Mutable vs Immutable. Everything in this lesson sorted by what it can change:
| Type | Mutable? | What can change | Notes |
|---|---|---|---|
String | no | nothing | every method returns a new String (or this when nothing changes); safe as a Map key because the hash never changes |
const string / literal | no | nothing | equal const strings are ONE canonical object: identical('hi', 'hi') is true |
s.codeUnits | no (view) | nothing; list-changing calls throw UnsupportedError | live wrapper, O(1) to create |
s.runes, Runes | no (view) | nothing | decoded on demand |
StringBuffer | yes | its content (write*, clear) | the String from toString() is a snapshot and never changes later |
RuneIterator | yes | its position (moveNext, reset, rawIndex =) | the string underneath is never touched |
RegExp, Match | no | nothing | safe to share and cache; reuse one RegExp |
final sb = StringBuffer(); is still mutable: final fixes the variable (D22), not the buffer. And a function that receives a StringSink can write into your buffer: pass the buffer only to code you trust.10. Choosing the right tool
| I need ... | Use | Cost | Why |
|---|---|---|---|
| to glue 2 or 3 known pieces | '$a, $b' or a + b | O(total) once | one allocation, nothing repeated |
| to build text in a loop | StringBuffer | O(total) for the whole loop | pieces are collected, one copy at the end |
| to join a list with a separator | list.join(', ') or sb.writeAll(list, ', ') | O(total) | separator only between elements |
| to repeat text | 'ab' * n | O(n·len) | sized exactly once |
| to build text from numbers | String.fromCharCodes | O(n) | handles pairs for numbers above 0xFFFF |
| an element by position | s[i] / codeUnitAt(i) | O(1) | array index, counts code units |
| to walk letters including emoji | s.runes / RuneIterator | O(n) | decodes surrogate pairs |
| to walk what the user sees | package:characters | O(n) | handles accents and ZWJ sequences |
| to find a fixed text | indexOf / contains | O(n·m) worst | plain scan; use KMP for huge n and m |
| to find a shape | RegExp | O(n) typical | compile once; beware backtracking |
| to change one piece | replaceRange, replaceFirst | O(n) | always a new String |
Quiz
Interview questions
Cheat sheet: all 94 members
Rules of thumb: a String never changes; indexes count code units; runes counts code points; loops that grow a string use StringBuffer; compile a RegExp once; replaceAll is literal; anchor patterns when you mean "whole string".
| Member | Mutates or new? | Time (n = length) | Throws |
|---|