Heroes / Guide / chapter 9

Strings

Reading, splitting and joining strings, distinguishing bytes from characters, and putting a value inside one.

A str contains UTF-8 text, an encoding in which a character can occupy multiple bytes. A string does not change after creation. len() measures length in bytes, and slice indices also refer to bytes.

Indexing gives you a byte

# Bytes, not characters: this counts the ASCII spaces.
function space_count(s: str) -> i64
    seen: i64 @= 0

    for i in range(from: 0, to: s.len())
        if s[i] == ' '
            seen @ seen + 1

    return seen
examples/gallery/07-strings.hero, lines 7–15

s[i] gives you a u8, a number from 0 to 255, because that is what a byte is. It is compared against ' ' in single quotes, which is how a single character is written, and a character is a number too. With double quotes you would have a string, and the two do not mix:

$ heroes check strings.hero
error[type_mismatch]: expected `u8`, found `str`
  at strings.hero:12:12
     |
  12 |         if s[i] == " "
     |            ^^^^^^^^^^^

Heroes uses different types for numbers and strings and does not convert between them automatically. The comparison in the example is therefore a compile error.

chars() gives you characters

# Characters, not bytes: this one is right for text.
function letters_of(s: str) -> [str]
    out: [str] @= []

    for c in s.chars()
        if c != " "
            out @ out.push(c)

    return out
examples/gallery/07-strings.hero, lines 17–25

chars() walks whole characters and hands each one back as a one-character str, which is why this function compares against " " in double quotes while the previous one used ' '. Use the byte loop for a protocol or a scanner; use chars() for anything a person will read.

Slicing through a character stops the program and reports an error. This prevents the operation from producing invalid UTF-8 text.

Splitting text into words

# Splitting on a single space, written out. There is no library to hide in.
function words_of(s: str) -> [str]
    out: [str] @= []
    start: i64 @= 0

    for i in range(from: 0, to: s.len())
        if s[i] == ' '
            if i > start
                out @ out.push(s.slice(from: start, to: i))
            start @ i + 1

    if s.len() > start
        out @ out.push(s.slice(from: start, to: s.len()))
    return out
examples/gallery/07-strings.hero, lines 27–40

Heroes has no built-in split function. This example splits text at spaces. The if i > start check avoids adding empty words when two spaces appear in a row.

slice(from:, to:) takes bytes, to excluded, and names both because they are the same type, the same rule that makes range name its ends.

Putting them back together

# `join` is the other direction, and the separator is an argument, not a
# convention.
function sentence_of(words: [str]) -> str
    return words.join(" ")
examples/gallery/07-strings.hero, lines 42–45
function initials_of(words: [str]) -> str
    out: str @= ""

    for w in words
        if w.len() > 0
            out @ out + w.slice(from: 0, to: 1)

    return out
examples/gallery/07-strings.hero, lines 47–54

join builds the result in one pass. The concatenation loop creates a new string on each iteration and copies the text already accumulated. The difference matters for long strings. join and repeat avoid these repeated copies.

Text with holes in it

A string can carry values. An f before the opening quote turns {n} into the text of n, written the way to_str would write it. Any expression may stand in a hole, and the hole ends at the brace that closes it, so a call or a lookup inside it reads as it would anywhere. {{ and }} each write one brace, and a lone } is an error, so a brace meant as text is always doubled. A string without the f keeps its braces as text, except where they would make a hole naming only what is in scope there, as in "{i + 1}": that is refused as a forgotten f, and braces meant as text are then written {{ and }} in an f string.

function line(at: i64, of: i64, star: Star) -> str
    return f"{at}/{of} {star.name} at magnitude {star.magnitude}"
examples/gallery/12-interpolation.hero, lines 20–21

A hole may hold what to_str can render: an integer, a float, bool or str. A record or an array has no text of its own, and a value that may be missing has to be made whole with .must() first. The compiler refuses the hole and its note says what to write instead. The whole example →

$ heroes run examples/gallery/12-interpolation.hero
1/2 Sirius at magnitude -1.46
2/2 Vega at magnitude 0.03
seen Sirius 2 times, {like this}
nested: 2 stars

Available escapes

In a string: \n, \t, \r, \\, \". In a character literal, \' replaces the last of them. Other escape sequences produce an error and a suggested fix:

$ heroes check strings.hero
error[unknown_escape]: `\d` is not an escape sequence — the escapes are \n \t \r \\ \"; write `\\` for a literal backslash
  at strings.hero:45:24
     |
  45 |     return words.join("\d")
     |                        ^^
  fix (certain): escape the backslash: `\\d`

Heroes reports unknown escapes, backslash sequences it does not recognise. To include a literal backslash, double it. In this example the compiler suggests \\d and can apply the fix automatically.

The program

function main()
    words = words_of("the quick brown fox")
    print(sentence_of(words))
    print(initials_of(words))
examples/gallery/07-strings.hero, lines 63–66
$ heroes run examples/gallery/07-strings.hero
the quick brown fox
tqbf

Next: Maps, where looking a key up hands you a value or an error, and there is no null to forget. Back to the chapter list. ← Previous: Generics and function values

Run a program of your own: it takes four commands. Or take the whole language in one file, and every program already written in it.