Skip to content
academia.sh

Lesson 04 / 23

String Templates

Introduces string templates, raw strings, and the `trimIndent` function; measures that a string built with a template falls under the content criterion, while a single-variable template stays a singleton.

Published Updated

In this lesson

  • Writes the `$name` and `${expression}` template forms and the case that needs an escape
  • Uses the triple-quoted raw string and the `trimIndent` function
  • Explains the equality criterion of a string built with a template and how it changes for a single-variable template
Contents

In the number types, automatic boxing showed that the same value can split into two objects. Strings ask this question from another direction: when I build a text with a template, do I get the same object as the text I write by hand? First we look at the template itself: how it is written, how it combines with a raw string, and what it changes in the output. Then we run the same templates through the equality measurement and look for the place where a single way of writing falls under two criteria.

Template, Raw String, and trimIndent

In Kotlin, the $ sign is used to place a value inside a double-quoted string. $name puts the value of a variable into the string, and ${expression} puts in the result of an expression. The expression form can also be a function call or a property access: ${items.size}, ${items.first().uppercase()}. If you want to write the $ character with its own meaning inside a string, you put a backslash in front of it: \$5.

A string that begins with three double quotes is a raw string. Line breaks and indentation are kept as they are and escape sequences do not work, but templates do. When a raw string spans several lines, the indentation at the start of each line becomes part of the text; the trimIndent function removes the common indentation and drops the empty first and last lines.

In this lesson too, the measurements are made with the helper file introduced in the first lesson. The Criterion class takes a value, its twin, and a different value, and asks three questions; the file is given below exactly as it was.

// Criterion.kt — classifies the criterion that makes two values equal
class Criterion(val label: String, first: Any?, twin: Any?, other: Any?) {
    val sameObject = first === twin
    val equalTwin = first == twin
    val equalOther = first == other

    val name: String = when {
        sameObject -> "singleton"
        !equalTwin -> "identity"
        !equalOther -> "content"
        else -> "partial-content"
    }

    fun report() {
        println("$label: same-object=$sameObject, twin=$equalTwin, other=$equalOther -> $name")
    }
}

fun summarize(checks: List<Criterion>) {
    for (name in listOf("singleton", "identity", "content", "partial-content")) {
        println("$name: ${checks.count { it.name == name }}")
    }
}

The program below runs these three things together.

// Main.kt — templates, raw string, and trimIndent
fun main() {
    val name = "Maya"
    val items = listOf("apple", "pear")
    println("Hello, $name")
    println("Item count: ${items.size}, first: ${items.first().uppercase()}")
    println("Price: \$5")
    val text = """
        name: $name
        items: ${items.joinToString()}
    """.trimIndent()
    println(text)
}
Hello, Maya
Item count: 2, first: APPLE
Price: $5
name: Maya
items: apple, pear

The first line places a single variable: Hello, Maya. The second places two expressions: items.size gives the length of the list, and items.first().uppercase() converts the first element to uppercase; Item count: 2, first: APPLE appears in the output. In the third line, the backslash saves the text $5 from being read as a template, and Price: $5 is printed. The raw string produces a two-line text from four lines of source code: the indentation is removed by trimIndent, and the lines name: Maya and items: apple, pear appear aligned in the output. items.joinToString() turns the list of elements into a single text, separated by commas.

The three forms of writing in the program do three separate jobs. The plain $name is the shortest form and puts in only the value of a name. The braced form is needed when the name alone is not enough, or when a letter follows the template immediately; the braces show clearly where the name ends. The raw string is useful in multi-line texts where escape sequences should be avoided, for example a configuration sample or a formatted message draft. trimIndent lets you keep the source code indented; the text in the output stays flush with the left edge.

LB24. Only simple expressions were measured inside a template; the behavior of a nested template, or of an expression that contains a string inside a string, is outside this lesson.

LB25. trimIndent removes the common indentation; what it does on lines with irregular indentation was not measured in this lesson.

The Criterion of a String Built with a Template

We return to the question of the lesson. In the earlier lesson, the same string constant became a single object in two variables and came out singleton. For a string built with a template we write three observations. template-built: "lang: $name" against "lang: $name", that is, two separate template expressions. single-variable-template: only "$name", a template with no other text, against the name variable. template-and-suffix: "$name.", that is, a variable plus a constant suffix.

In the second lesson we saw that strings built with joinToString and buildString came out as content. A template is also a way of writing that builds a string at run time; so we expect the same result. We measure the expectation, because how the compiler handles a template is not a language rule but an implementation detail, and a detail cannot be derived from the text. In each observation the twin is obtained by writing the same template a second time or by a constant; the different value carries the letter change kotlim.

// Main.kt — a string built with a template
fun main() {
    val name = "kotlin"
    val checks = listOf(
        Criterion("template-built", "lang: $name", "lang: $name", "lang: kotlim"),
        Criterion("single-variable-template", name, "$name", "kotlim"),
        Criterion("template-and-suffix", "$name.", "$name.", "kotlim."),
    )
    for (check in checks) {
        check.report()
    }
    summarize(checks)
}
template-built: same-object=false, twin=true, other=false -> content
single-variable-template: same-object=true, twin=true, other=false -> singleton
template-and-suffix: same-object=false, twin=true, other=false -> content
singleton: 1
identity: 0
content: 2
partial-content: 0

template-built is content. Two template expressions produce two separate strings; same-object=false, but they are equal by == and not equal to the different text. template-and-suffix is also content; a template that joins a variable with a constant suffix builds a new object every time it runs.

single-variable-template, on the other hand, is a singleton: when "$name" is written, the object obtained is the very object that the name variable points to, same-object=true. A template to which no other text is added does not build a new string.

The common point of these three observations is that all of them use the same name variable. The variable is single, and the template form changes. This choice is deliberate: we want to see that what affects the criterion is not the value of the variable but the form of the template. The period in template-and-suffix is a constant text that follows the name immediately; the template does not count this period as part of the name, it places only the name variable and leaves the period as it is.

LB26. These three observations were measured with Kotlin 2.4.21; that "$s" does not build a new object is the compiler’s behavior in this version, and it is not claimed that it will be the same in other versions.

LB27. The name variable here is a val bound to a string constant; that the same result comes out when the variable is bound to a string built at run time was not measured.

This result suggests a habit when working with strings. When comparing one text with another for equality, there is no need to think about whether the text came from a constant or from a template; == looks at the text in both cases. Which object the text is only shows up when === is asked, and then the answer changes according to whether there is anything else inside the template.

When the Dollar Sign Starts a Template

Above we saw that the dollar sign can be escaped with \$5. But does every $ ask for an escape? The block below prints seven ways of writing one after another: a $ followed by a digit, a $ surrounded by spaces, a $ at the end of a string, two separate escape routes, and a period written right after a variable name.

// Main.kt — when the dollar sign starts a template
fun main() {
    val name = "kotlin"
    println("price: $5")
    println("a $ b")
    println("last: $")
    println("${'$'}name")
    println("\$name")
    println("$name.length")
    println("${name.length}")
}
price: $5
a $ b
last: $
$name
$name
kotlin.length
6

In the first three lines, $ was printed as it is: $5, a $ b, and the $ standing at the end. The rule is this: if the first letter of an identifier comes after the $ sign, a template starts; if a digit, a space, or the end of the string comes, the $ is an ordinary character. For this reason "price: $5" worked without asking for an escape. The fourth and fifth lines give two ways of writing a text that wants a $ at the start of a name: "${'$'}name" and "\$name" print the same text $name.

The sixth line shows a frequent mistake. A person who writes "$name.length" expects 6, and kotlin.length appears on the screen: the plain $name form takes only the name, and what comes after the period stays as text. To reach the property, "${name.length}" is written, and the last line gives 6. The reason for the mechanism is that the plain form reads only a name, not an expression; the braces mark the boundary of the expression explicitly.

Java comparison: in Java, + concatenation, String.format, or formatted is used to put a value into a string; $ is not a special character. A person moving from Java to Kotlin may assume that they will always escape the $ sign. This observation measures only the Kotlin side.

LB28. These seven ways of writing were measured with a single name variable; how names in which a Unicode letter follows the $ are parsed was not measured in this lesson.

A String Is Immutable

In Kotlin, String is immutable: no method changes the content of an existing object. The methods that make a change return a new string. The block below first shows this with uppercase, then runs four methods through the equality measurement. The question is this: when a method changes nothing, does it build a new object, or does it return the one in hand?

// Main.kt — a string is immutable, methods may return a new object
fun main() {
    val text = "kotlin"
    val upper = text.uppercase()
    println(text)
    println(upper)
    val checks = listOf(
        Criterion("replace-no-match", text, text.replace("x", "y"), "kotlim"),
        Criterion("replace-match", text, text.replace("k", "k"), "kotlim"),
        Criterion("uppercase-lowercase", text, text.uppercase().lowercase(), "kotlim"),
        Criterion("trim-no-spaces", text, text.trim(), "kotlim"),
    )
    for (check in checks) {
        check.report()
    }
    summarize(checks)
}
kotlin
KOTLIN
replace-no-match: same-object=true, twin=true, other=false -> singleton
replace-match: same-object=false, twin=true, other=false -> content
uppercase-lowercase: same-object=false, twin=true, other=false -> content
trim-no-spaces: same-object=true, twin=true, other=false -> singleton
singleton: 2
identity: 0
content: 2
partial-content: 0

The first two lines confirm immutability. While text is still kotlin, upper carries the value KOTLIN as a separate object. The measurement lines, on the other hand, split into two criteria. replace-no-match is a singleton: replace, which tries to replace the text "x" with "y", returns the same object when it finds no match. trim-no-spaces is also a singleton: for a string with no spaces to trim, trim returns its own object. replace-match is content: when a match is found, a new string is built even if the replacing text is the same. uppercase-lowercase is also content, because the two conversions produce successive new objects.

This extends the rule we saw in the second lesson: what determines the criterion is where the value comes from. Here the source is a method, and when the method has no work to do, it returns its input. This behavior is not a guarantee of the language but a measured result; code that tests with === is fragile for this reason. In Java, String is also immutable and methods similarly return new strings, but the Java side was not measured in this section.

LB29. Four methods were measured with a single string; that the same “no work, same object” behavior holds for trimStart, trimEnd, and substring, other than trim, was not measured in this lesson.

Length, Escape, and UTF-16

length gives the number of characters in a string, but the word “character” is tricky here. The block below compares a string with an escape sequence against its raw counterpart, then asks the length and the code point count of an emoji character. At the end, the trimming of a raw string from the line starts with trimMargin is shown.

// Main.kt — length, escape sequence, and UTF-16
fun main() {
    val escaped = "a\nb"
    val raw = """a\nb"""
    println(escaped.length)
    println(raw.length)
    val face = "\uD83D\uDE00"
    println(face.length)
    println(face.codePointCount(0, face.length))
    val margin = """
        |line one
        |line two
    """.trimMargin()
    println(margin.lines().size)
    println(margin.lines().last())
}
3
4
2
1
2
line two

The length of the string "a\nb" comes out as 3: \n is a single line-break character. The same writing gives 4 in a raw string, because the escape does not work in a raw string and the backslash and the n are counted as two separate characters. Whether the escape works can be read from these two numbers.

For the emoji, length gives 2 and codePointCount gives 1. Kotlin strings are held in UTF-16 encoding, and this character needs two code units (a surrogate pair). length counts code units, not code points. The frequent mistake is to use length for a “character count” shown to the user; in a text that contains emoji, the number comes out wrong. In Java, String.length() gives the same code unit count; this comes from the JVM string being under the Kotlin string.

The last two lines are the result of trimMargin. Two lines remain from the two lines that begin with the | sign, and the second reads as line two. trimIndent removes the common indentation, while trimMargin drops, on each line, the part up to the | sign; in a text with irregular indentation the second is more predictable. This comparison was measured only with this two-line example.

LB30. The length measurement was made with one emoji and one escape sequence; what the length result is for combining characters (for example, a letter with a separate accent mark) was not measured in this lesson.

Habits That Come from the Measurements

The most frequent mistake in writing templates is to confuse the plain $name form with writing an expression. The rule is simple: if you need a property, a method, or any operation, you use braces. In the measurement the output of "$name.length" was kotlin.length; this mistake is not caught at compile time, because the syntax is valid and the result only looks wrong. Mistakes of this kind are noticed not in tests but by looking at the output. For this reason, when writing messages with templates, it should become a habit that everything that comes after a period is inside the braces.

The immutability of strings has two consequences. The first is that forgetting to assign the result of a method such as uppercase to a variable means the change is lost: code that writes the line text.uppercase() alone does nothing. The second is that growing a string with + in a loop produces a new object at every step; for multi-step concatenation, StringBuilder or buildString is used. The same rule holds in Java, and Kotlin does not bring a separate guarantee here.

On length, one should not trust the word “character”. length counts UTF-16 code units; for this reason emoji and characters in some writing systems count as two units. A form that shows the user a limit of “at most 20 characters” and checks this number with length applies the limit wrongly for someone who uses emoji. If the code point count is needed, codePointCount is used; the count of characters as the user perceives them (graphemes) needs a separate library and was not measured in this lesson.

In raw strings escapes do not work; for this reason they are suitable for file paths, regular expressions, and multi-line texts, but a person who writes \n does not get a line break. In the measurement the same writing gave four characters in a raw string and three in an escaped string. Being able to read from the number which one was used is useful when debugging.

The Bounding Measurement

The criterion of a string built with a template is content, but this generalization leaves the single-variable template outside. When "$name" places only a variable, it came out singleton; the moment text or a suffix is added, it turned to content. The same form of writing, that is, a template, can fall under two criteria, and the distinction depends on whether there is anything else inside the template.

A rule can be drawn from here: use == instead of comparing strings with ===. The bounding measurement shows the reason for this rule: the same template form can give a singleton or content; == gives the right answer in both cases.

Summary

  • $name and ${expression} place a value into a string; to write the $ character in a string, \$ is used.
  • The triple-quoted raw string keeps line breaks; trimIndent removes the common indentation.
  • Two separate template expressions are separate objects even if they produce the same text.
  • The "$name" template, which places only a variable, gives the variable’s own object in Kotlin 2.4.21.
  • This lesson’s criterion: content; bounding measurement: a single-variable template is a singleton.

Next Step

We have seen how strings are built. In the next lesson we move on to the flow of the program: if and when expressions, for loops, and ranges. While when matches a value to a branch, it uses ==; we measure which example fails to hit the branch. In that lesson the identity criterion will meet us again, and we will see that the is check does not ask the same question as ==.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close