JavaRush /Courses /Kotlin SELF /Indexes and substrings in Kotlin: length, lastIndex, inde...

Indexes and substrings in Kotlin: length, lastIndex, indexOf, substring

Kotlin SELF
Level 9, Lesson 3
Available

1. A string as a sequence of characters

When you start “parsing” a string, your brain automatically wants to treat it like text from a book. But the computer does not need a book—it needs a sequence of characters, where each character sits in its own place and has a number. That number is called an index, and in Kotlin (as in almost everywhere) indexing starts from zero.

Zero-based indexes: why the first character is 0

The idea “the first element has index 0” may feel like mockery at first, but it quickly becomes convenient: index = “how many characters come before the current one”. Before the first character there are zero characters—so the index is 0. Before the second there is one—so the index is 1. This will also come in handy when we start cutting with substring().

Let’s look at the string "Kotlin":

Character
K
o
t
l
i
n
Index
0
1
2
3
4
5

The number of characters is 6, and the last index is 5.

length and lastIndex: don’t confuse “size” and “last index”

The length property tells you how many characters are in the string. And lastIndex is the index of the last character.

It’s important to feel the difference: length is “count”, lastIndex is “last position”. In collections and arrays, the exact same logic is used: the last index equals size - 1.

Mini example:

fun main() {
    val s = "Kotlin"

    println(s.length)      // 6
    println(s.lastIndex)   // 5
}

If the string is empty (""), then length will be 0, and lastIndex becomes -1. That sounds odd, but it is logical: “there is no last character”, so the “last index” goes into the negative range. That is exactly why we talk so much about boundary checks.

2. Accessing a character by index: s[i] and careful boundary checks

Now that indexes exist, your hands want to do this: “give me character number 0”. And Kotlin really does allow accessing a character by index using square brackets. But there is a catch: if the index is invalid, the program will crash with an exception (and you want to write code that does not crash because of a single misplaced space).

s[i] returns Char: text is not always a string

The operator s[i] returns a single character of type Char. A character is not a string. A string is many characters.

fun main() {
    val s = "Kotlin"

    val first: Char = s[0]
    val last: Char = s[s.lastIndex]

    println(first) // K
    println(last)  // n
}

If you try to do s[6] for "Kotlin", the program will crash: the indexes there are only 0..5.

Boundary checks: “before you take it—make sure you can”

Here it helps to keep a short rule in mind: valid indexes are all numbers that are not less than 0 and less than length.

A mini helper that safely returns a character or '?' if the index is bad:

fun charOrQuestion(text: String, index: Int): Char {
    if (index < 0 || index >= text.length) return '?'
    return text[index]
}

fun main() {
    println(charOrQuestion("Kotlin", 2))   // t
    println(charOrQuestion("Kotlin", 99))  // ?
}

Empty string: the smallest but very tricky case

An empty string is a string of length 0. It has neither a first nor a last character. This means that even s[0] is dangerous.

fun main() {
    val s = ""

    println(s.length)    // 0
    println(s.lastIndex) // -1
    println(s[0])        // you must not do this — it will be an error
}

A good habit that saves your nerves: before accessing s[0], ask yourself: “is the string definitely not empty?”

3. Searching in a string: indexOf() and checks with contains(), startsWith(), endsWith()

Usually, we parse strings not out of boredom, but because the string contains a delimiter: a space, @, =, a colon, a dot, and so on. The scenario is often: “find where the delimiter is and split the string into two parts”.

indexOf(...): the -1 convention

The method indexOf(...) searches for a substring (or a character) and returns the index of the first occurrence. And if it does not find it, it returns -1. This is a very important convention: -1 means “not found”.

fun main() {
    val email = "user@example.com"

    val at = email.indexOf('@')
    println(at) // 4

    val slash = email.indexOf('/')
    println(slash) // -1
}

If you forget to check for -1 and start cutting the string at that index, you will get either a boundary error or a very strange result.

contains(), startsWith(), endsWith(): when you don’t need indexes

Sometimes you do not need an index—you just need “yes/no”. Then it is more convenient to use the check methods.

fun main() {
    val filename = "report.txt"

    println(filename.contains("."))      // true
    println(filename.startsWith("rep"))  // true
    println(filename.endsWith(".txt"))   // true
}

These methods answer simple questions:

  • contains() — does the string contain the given substring?
  • startsWith() — does the string start with the given substring?
  • endsWith() — does the string end with the given substring?

These checks often make code safer: you do less manual index work, and there is less chance of missing “by 1”.

4. substring(): cutting a string into parts without stepping on rakes

If indexes are “coordinates” in a string, then substring() is “scissors”. Almost any string parsing boils down to one template: find the delimiter index and cut out the left/right part.

The main rule of substring(start, end): end is excluded

The method substring(start, end) takes characters from position start inclusive, while end is not included. This is the classic “half-open interval” style, and it is incredibly convenient, but confusing at first.

Diagram:

"Kotlin"
 012345

substring(0, 3) -> take indexes 0,1,2 -> "Kot"

Example:

fun main() {
    val s = "Kotlin"

    println(s.substring(0, 3)) // Kot
    println(s.substring(3, 6)) // lin
}

Note: end can be equal to length, and that is fine. “Up to the end of the string” is exactly end = length.

substring(start) — from a position to the end

There is a convenient form substring(start)—it takes from start to the end of the string.

fun main() {
    val s = "Kotlin"

    println(s.substring(2)) // tlin
}

This is very convenient for the right part after a delimiter: “after @ to the end”, “after = to the end”.

Bounds for substring(): where the program can crash

substring() requires valid bounds. Simplified: start and end must be in the range 0..length, and start <= end. If you violate this—you get an error.

That is why an experienced programmer almost never writes substring(...) “straight away”: they first check that the indexes are valid.

5. The “found → checked → cut” pattern

Now we will assemble everything into one robust pattern and add it to our training console application. Imagine we are making a simple “User Profile” program: the user enters a full name as one string and an email, and we output a neat card. Today we will focus specifically on how to safely extract parts of these strings using indexes.

Reality is harsh: people type names however they want. Yes, they put spaces in weird places. Yes, they sometimes forget @. These are not “bad users”; this is the “real world”.

Parsing a full name "First Last" via indexOf(' ')

The simplest version of a full name is two words separated by a space. For now, we intentionally do not use split() (that will be in the next lecture), so we will do a “manual cut”: find the space index and cut the left and right parts.

fun main() {
    val fullName = "Ivan Petrov"
    val space = fullName.indexOf(' ')

    val firstName = if (space == -1) fullName else fullName.substring(0, space)
    val lastName = if (space == -1) "" else fullName.substring(space + 1)

    println(firstName) // Ivan
    println(lastName)  // Petrov
}

Pay attention to space + 1: we do not want the space to end up in the last name. If the space is the last character of the string, substring(space + 1) turns into substring(length), and that is just an empty string (and that is fine).

Extracting the domain from an email: indexOf('@') and checks

Email is a classic example of a string with a delimiter. We want the domain: everything after @. But we need to protect against cases like "@example.com" and "user@".

fun extractDomain(email: String): String {
    val at = email.indexOf('@')
    if (at == -1) return "INVALID"
    if (at == 0) return "INVALID"
    if (at == email.lastIndex) return "INVALID"

    return email.substring(at + 1)
}

fun main() {
    println(extractDomain("user@example.com")) // example.com
    println(extractDomain("user@"))            // INVALID
}

Here we use email.lastIndex to check: “is @ the last character or not”. And this is exactly the case where lastIndex reads better than length - 1.

Parsing a string in the format "key=value"

A very popular input format is key=value. Today we will make a simple version: find = and split the string.

fun main() {
    val line = "city=Tokio"
    val eq = line.indexOf('=')

    val key = if (eq == -1) "" else line.substring(0, eq)
    val value = if (eq == -1) "" else line.substring(eq + 1)

    println("key='$key', value='$value'") // key='city', value='Tokio'
}

Same pattern: found the index → checked for -1 → cut the left and right parts.

Building a “profile card” and not mixing parsing with output

Now let’s assemble a small piece of our application: read fullName and email, compute firstName, lastName, domain, and print the card.

fun extractDomain(email: String): String {
    val at = email.indexOf('@')
    if (at <= 0 || at == email.lastIndex) return "INVALID"
    return email.substring(at + 1)
}

fun main() {
    print("Enter first and last name: ")
    val fullName = readln()

    print("Enter email: ")
    val email = readln()

    val space = fullName.indexOf(' ')
    val firstName = if (space == -1) fullName else fullName.substring(0, space)
    val lastName = if (space == -1) "" else fullName.substring(space + 1)

    val domain = extractDomain(email)

    val card = """
        |Profile
        |-------
        |First name: $firstName
        |Last name: $lastName
        |Email domain: $domain
    """.trimMargin()

    println(card)
}

Here the parsing logic (indexes, indexOf(), substring()) is close by, but it is not mixed into one giant line. This style is easier to debug: if the card looks weird, you can print intermediate values like space, firstName, lastName.

Cheat sheet for indexes and tricky spots

When you work with strings, it helps to keep a small cheat-sheet table in your head: what gets returned and what you must check.

Operation What it returns What to watch to avoid crashing
s.length
number of characters if 0, the string is empty
s.lastIndex
index of the last character will be -1 if the string is empty
s[i]
Char
by index
i
must be
0 <= i < length
s.indexOf(x)
index or -1 -1 must be handled
s.substring(a, b)
a piece of the string
a
and
b
in
0..length
, and
a <= b
s.substring(a)
from
a
to the end
a
must be in
0..length

And one more important “bridge” to understanding: zero-based indexes are a general Kotlin principle for indexable structures. It shows up the same way in arrays, in lists, and in loops over indices.

6. Common mistakes when working with indexes and substrings

Mistake #1: confusing length and “last index”.
A very common slip is thinking that if the string length is 6, then the last index is 6. In reality, with zero-based indexing the last index is always 1 less. This leads to “off by 1” bugs: you either take the wrong character, or you crash by going out of bounds.

Mistake #2: doing s[0] without checking for an empty string.
An empty string appears more often than it seems: the user pressed Enter, empty input arrived, something concatenated into "". If after that you do text[0], the program crashes. The right habit is to first check text.isEmpty() or at least text.length == 0, and only then touch indexes.

Mistake #3: ignoring indexOf(...) == -1.
indexOf does not “complain”; it honestly returns -1. But if you immediately use that -1 as a boundary in substring(), chaos begins: at best you get an exception, at worst you cut the wrong thing and get a “quietly wrong” result. So the combo “found → checked → cut” must become a reflex.

Mistake #4: forgetting that substring(start, end) does not include end.
This is a source of especially annoying bugs: the program seems to work but gives an “almost correct” answer—one character shorter or longer. The best cure is to always say to yourself: “start inclusive, end exclusive”, and draw a small diagram on paper when you are unsure.

Mistake #5: cutting the right part without removing the delimiter (substring(at) instead of substring(at + 1)).
If you split "user@example.com" by @, you want the right part without @. That means substring(at + 1). If you write substring(at), the domain will start with @, and then your validity logic and string comparisons can start behaving oddly. This is especially unpleasant in programs where the domain is later compared to "gmail.com".

Mistake #6: mixing string parsing and result formatting into one huge string.
Technically you can write one giant println("First name: ${...} ..."), but that almost guarantees a bug and poor readability. It is more reliable to first compute val firstName, val lastName, val domain, and only then build the final output. Such code is easier to read, easier to test, and easier to fix when the user enters something “creative” again.

1
Task
Kotlin SELF, level 9, lesson 3
Locked
String passport
String passport
1
Task
Kotlin SELF, level 9, lesson 3
Locked
Character at index
Character at index
1
Task
Kotlin SELF, level 9, lesson 3
Locked
Delimiter search
Delimiter search
1
Task
Kotlin SELF, level 9, lesson 3
Locked
First and last name
First and last name
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION