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.

How to read the member cards. Each card shows the signature copied from the SDK, a plain-words meaning, a badge, the time it costs (and why), what it throws, and an example with its exact output (every example was run with Dart 3.11 and is re-checked by 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

A 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:

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?".

The Dart VM stores a string whose units are all 0 to 255 (Latin-1) with one byte per unit and any other string with two bytes per unit; a String also remembers its hash code once computed. These are implementation details of the VM: the language only promises a row of UTF-16 code units. On the web (dart2js) a Dart String IS a JavaScript string, so identical on two equal strings is true there.
Because strings are immutable there is no 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

Think of a postcard that can only hold 16-bit stamps. Most letters need one stamp. Emoji and some rare scripts need TWO stamps stuck together (a surrogate pair). A rune (a Unicode code point) is the real letter; a code unit is one stamp. And what you SEE as one character on screen can even be several runes: that is a grapheme (a letter plus its accent, a family emoji built from three people joined by invisible glue).

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):

TextLooks likelength (code units)runes.lengthGraphemes
'a'1 letter111
'é' (U+00E9)1 letter111
'é'1 letter221
'😀'1 emoji211
family emoji (3 people + 2 ZWJ)1 emoji851
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.
Dart = UTF-16. Indexes and 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

Comparing two words in a dictionary: look at the first letters; if they differ the order is decided, otherwise move to the second letters, and so on. A word that is a prefix of another comes first. Searching is sliding a stencil of the pattern along the text and checking whether every hole shows the same letter.

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

A tailor with a bolt of cloth that can never be stretched or re-sewn: to shorten a sleeve (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

A 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

You are writing a long letter on stone tablets. Method 1 (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)).

ns += t: n(n+1)/2 unit writesStringBuffer: ≈ n unit writesratio
1055105.5×
1005,05010050.5×
1,000500,5001,000500.5×
10,00050,005,00010,0005,000.5×
100,0005,000,050,000100,00050,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 units += 'x'StringBuffer.writeList.join()measured ratio (+= / StringBuffer)
10,0002.6 ms0.10 ms0.05 msabout 26× (20× to 26× over my runs)
100,000212 ms0.9 ms0.9 msabout 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.

The web is different. When Dart is compiled to JavaScript (dart2js), a String is a JavaScript string and JS engines usually implement + 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.
When is + 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

A 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.
User text inside a pattern must go through 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:

TypeMutable?What can changeNotes
Stringnonothingevery method returns a new String (or this when nothing changes); safe as a Map key because the hash never changes
const string / literalnonothingequal const strings are ONE canonical object: identical('hi', 'hi') is true
s.codeUnitsno (view)nothing; list-changing calls throw UnsupportedErrorlive wrapper, O(1) to create
s.runes, Runesno (view)nothingdecoded on demand
StringBufferyesits content (write*, clear)the String from toString() is a snapshot and never changes later
RuneIteratoryesits position (moveNext, reset, rawIndex =)the string underneath is never touched
RegExp, Matchnonothingsafe 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 ...UseCostWhy
to glue 2 or 3 known pieces'$a, $b' or a + bO(total) onceone allocation, nothing repeated
to build text in a loopStringBufferO(total) for the whole looppieces are collected, one copy at the end
to join a list with a separatorlist.join(', ') or sb.writeAll(list, ', ')O(total)separator only between elements
to repeat text'ab' * nO(n·len)sized exactly once
to build text from numbersString.fromCharCodesO(n)handles pairs for numbers above 0xFFFF
an element by positions[i] / codeUnitAt(i)O(1)array index, counts code units
to walk letters including emojis.runes / RuneIteratorO(n)decodes surrogate pairs
to walk what the user seespackage:charactersO(n)handles accents and ZWJ sequences
to find a fixed textindexOf / containsO(n·m) worstplain scan; use KMP for huge n and m
to find a shapeRegExpO(n) typicalcompile once; beware backtracking
to change one piecereplaceRange, replaceFirstO(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".

MemberMutates or new?Time (n = length)Throws