JavaRush/Courses/Kotlin SELF/Decomposition into functions: input, comparison, messages...

Decomposition into functions: input, comparison, messages, and contracts

Open

1. Why a “wall of code” in main() gets in the way

If you are just starting to program, it is very easy to write all the game code directly in main(): read a string, parse it, compare, print a hint, increment a counter — and repeat. At first it feels fast. But after 30–40 lines you will find that you are afraid to touch this code, because any change breaks something “somewhere else”. Functions are like storage boxes: you put things in their places, and your head stops overheating.

Imagine the game is a kitchen. main() is the countertop where you cook. If everything is on the counter at once (flour, eggs, knives, pots, the microwave manual, and also a keyboard you accidentally left there), cooking turns into a quest. Functions let you put “repeated ingredients” and “small operations” into separate containers.

In our game it is especially convenient to move into functions:

  • input and validation of the guess (user mistakes almost always happen there);
  • comparing the guess with the secret (this is pure logic; it is convenient to isolate);
  • building messages (“too low/too high/guessed it”), so strings do not get smeared across the project.

A small diagram of where we are heading:

flowchart TD
    A["main(): game scenario"] --> B["readGuessOrNull(): input + validation"]
    A --> C["compareGuess(): comparison"]
    A --> D["hintText(): hint text"]

2. Function contract

Before writing functions, it is important to learn how to state their contract. A contract is a simple agreement: “what the function expects as input”, “what it returns”, “what a special result means (for example null)”, and “whether it performs side effects (prints something, reads input)”.

If you do not pin down the contract, a classic situation begins: you write readGuess(), and two hours later you ask yourself: “Does it print the prompt? Does it validate the range? And if the user typed abc — does it crash or return 0?” And now you are debugging not the game, but your own guesses about your own code.

It is convenient to keep contracts in mind as a small table:

Function Input Output Side effects What “error” means
readGuessOrNull(min, max)
input range
Int?
reads
readln()
, prints a prompt
null
= “failed to obtain a valid number”
compareGuess(guess, secret)
two numbers
Int (-1/0/1)
none not expected (input is valid)
hintText(compareResult)
-1/0/1
String
none an “unknown code” can be handled via
else

Notice: we intentionally separate the “dirty” world (user input, errors, spaces, letters) and the “clean” world (comparison of two Int values). This makes life much easier.

3. Reading a guess: readGuessOrNull(min, max)

User input is where the game breaks most often. The user may enter 42, or " 42 ", or "forty two" (and be sure they helped). So our input function must be resilient: it must not crash when the input is not a number, and it must filter out values outside the range.

The main idea of the contract here is: we return Int?. If everything is good — return the number. If not — return null. This is honest and clean: the type Int? immediately shows that the result may be absent, and the compiler will force us to handle that case.

Basic implementation with trim() and toIntOrNull()

Let us start with a very straightforward variant. It is not perfect, but it is already useful and understandable.

fun readGuessOrNull(min: Int, max: Int): Int? {
    print("Enter a number from $min to $max: ")
    val raw = readln().trim()

    val guess = raw.toIntOrNull() ?: return null
    if (guess !in min..max) return null

    return guess
}

There are several important details here.

First, trim() removes spaces at the edges — so the string " 42 " becomes "42". Second, toIntOrNull() tries to parse an Int, but instead of crashing it returns null if the string is not a number. Third, we check the range: guess !in min..max. And finally, early return null prevents the function from growing into nested if blocks.

The pattern “read a string → prepare → parse” appears in Kotlin all the time, especially in console tasks, and is often packaged as small helper functions for reading types.

Should the function print an error message inside itself?

Now a small architectural dilemma (without big words). There are two approaches.

If readGuessOrNull() prints “Input error” itself, then main() gets shorter, but the function starts being responsible for two things at once: input and messages. If the function simply returns null, and the message is printed by main(), then the function becomes “cleaner”, and the messaging scenario is controlled in one place.

For a learning project, it is most often convenient to keep the function neutral: let it return null, and let the caller print the message. Then the game can easily change the messaging style without touching parsing.

If you still want to give the user an immediate hint, you can make a variant that prints text, but it is better to make the contract explicit (at least in the name or in a comment).

fun readGuessOrNullWithMessage(min: Int, max: Int): Int? {
    val guess = readGuessOrNull(min, max) ?: run {
        println("Invalid input") // message is here, but then the function is “chatty”
        return null
    }
    return guess
}

Note: here run { } is used as a block for a neat early exit. If you are not yet confident with run, you can skip this variant and keep everything in main().

4. Comparing numbers: compareGuess(guess, secret)

Once input has turned into a valid number, the nicest part begins: ordinary logic. Here we compare the guess with the secret and want one of three outcomes: “lower”, “higher”, “equal”.

Important: this function must not read input and must not print messages. It must do exactly one thing: compare two Int values. Then it is easy to test separately (in your head, or with a few small calls).

Let us agree on the result format:

  • -1 if the guess is smaller than the secret;
  • 0 if the user guessed it;
  • 1 if the guess is larger than the secret.
fun compareGuess(guess: Int, secret: Int): Int {
    return when {
        guess < secret -> -1
        guess > secret -> 1
        else -> 0
    }
}

Why not Boolean? Because Boolean gives only two states, and we need three. Why not a string? Because a string is already a “human-facing representation”, not logic. We will make the string in the next function.

A small self-check (just to feel that the function behaves as expected):

fun main() {
    println(compareGuess(10, 20))  // -1
    println(compareGuess(30, 20))  // 1
    println(compareGuess(20, 20))  // 0
}

5. Hint text: hintText(compareResult)

Messages are what make the game “alive”. But if texts are scattered across the code, it becomes hard to change the style later, fix a typo, or add an “exclamation of mood”. So it is logical to keep hint texts in one place.

We have already chosen a comparison result code (-1/0/1), so we can make a function that turns this code into a human-readable phrase.

fun hintText(compareResult: Int): String {
    return when (compareResult) {
        -1 -> "Too low"
        1 -> "Too high"
        else -> "Guessed it!"
    }
}

Notice the else. We could write 0 -> "Guessed it!", but else here acts as a “safety net”: if someone accidentally returns, say, 2, we still get some message rather than emptiness. In production this is debatable, but in learning code it can even be more useful because the game will not “fall into silence”.

Quick mini-test:

fun main() {
    println(hintText(-1)) // Too low
    println(hintText(1))  // Too high
    println(hintText(0))  // Guessed it!
}

6. Attempt scenario and mini-tests

We will assemble a full attempt loop in the next lecture, but even now it is useful to see how functions turn main() into a readable scenario, and how to test them “in parts”.

Glue everything into “one attempt”

Imagine we are doing only one attempt (so we do not touch while too early) — just a demonstration of composition.

fun main() {
    val min = 1
    val max = 100
    val secret = 42 // temporarily fixed for the example

    val guess = readGuessOrNull(min, max)
    if (guess == null) {
        println("Invalid input")
        return
    }

    val result = compareGuess(guess, secret)
    println(hintText(result))
}

What is nice here: the code reads “like text”.

First we set the range and the secret. Then we get a guess. If there is no guess — we honestly stop. If there is one — we compare and print the hint. In this fragment there is not a single detail about toIntOrNull(), trim(), etc. Those details are “hidden” in a separate function, and main() does not have to remember how exactly we clean the string there.

Helper functions for scenario readability

When a project grows, even small bits like an “attempt header” or an “end-of-game message” start to be duplicated. Today we are not yet writing the final game logic end-to-end, but we can prepare a few “text” functions that will make the next step easier.

For example, a function for the line “Attempt X of Y”:

fun attemptHeader(attempt: Int, maxAttempts: Int): String {
    return "Attempt $attempt of $maxAttempts"
}

Or a function for the final win message (still without loop details):

fun winText(attempt: Int): String {
    return "Win in $attempt attempts!"
}

Why is this useful? Because a string is also part of the logic. It affects how the user understands the rules of the game. If you keep it as a separate function, you are less likely to accidentally show “Attempt 0 of 7” in one place and “Attempt 1 of 7” in another.

How to manually test functions before assembling the game

While we do not have a full loop, it may seem impossible to test the game. In fact, it is possible, and it is a very useful habit: test functions with small calls. It is almost like “playing the game in parts” without assembling the whole box.

The most common trick is to temporarily fix the secret so behavior is reproducible (randomness gets in the way of debugging).

fun main() {
    val secret = 50

    val result1 = compareGuess(10, secret)
    println(hintText(result1)) // Too low

    val result2 = compareGuess(90, secret)
    println(hintText(result2)) // Too high

    val result3 = compareGuess(50, secret)
    println(hintText(result3)) // Guessed it!
}

This way you can verify that the comparison and the hint texts “match in meaning”. If you accidentally swap the branches guess < secret and guess > secret, it will show up immediately: you will get “Too high” where it should be “Too low”. And you will catch the bug before you add the loop and the attempt counter.

7. Typical mistakes when decomposing input and logic

Mistake No. 1: the input function returns a “magic number” instead of null.
A very common variant: readGuess() returns 0 on error. Then it suddenly turns out that 0 can be a valid number (or will become valid if you change the range to 0..100). As a result, an input error turns into “normal input” and breaks the game logic quietly and unpleasantly. Int? solves this at the type level: an error cannot be confused with a number.

Mistake No. 2: mixing input, comparison, and printing in one function “because it is shorter”.
At first it seems that makeAttempt() is ideal: it reads a number, compares, prints “Too high”, and increments the attempt itself. But as soon as you need to change the policy “does invalid input consume an attempt”, you will start cutting this function into pieces in panic mode. Better to separate from the start: input separately, comparison separately, text separately.

Mistake No. 3: using toInt() instead of toIntOrNull() for user input.
toInt() is good when you are sure about the data (for example, inside your own code). But user input is unreliable by definition, and toInt() will throw an exception and terminate the program. toIntOrNull() gives exactly what you need for the game: “did we get a number or not”.

Mistake No. 4: validating the range after comparing with the secret.
Sometimes people do this: “first I will compare with the secret, then I will check whether the number is in range”. Then the user can enter 1000000, and you will honestly say “Too high”, even though by the game rules that number should not participate at all. The range is part of the input contract, so validation must happen before comparison.

Mistake No. 5: message texts are scattered across the code and start to diverge.
Today you wrote “Too high”, tomorrow in another place “Way too high!”, the day after tomorrow “High”. As a result, the user gets the feeling the game was written by three different people (and one of them is you on Monday morning). The hintText() function and small text helpers help keep a consistent style.

1
Task
Kotlin SELF,  15 level,  2 lecture
Locked
Comparison code
Comparison code
1
Task
Kotlin SELF,  15 level,  2 lecture
Locked
Decoding the code
Decoding the code
1
Task
Kotlin SELF,  15 level,  2 lecture
Locked
Number by the rules
Number by the rules
1
Task
Kotlin SELF,  15 level,  2 lecture
Locked
Single check
Single check
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet