CodeGym /Kursy /Kotlin SELF /try/catch wokol operacji plikowych

try/catch wokol operacji plikowych

Kotlin SELF
Poziom 45, Lekcja 2
Dostępny

1. Dlaczego try/catch jest potrzebny nawet po sprawdzeniach wstepnych

Kiedy po raz pierwszy robisz sprawdzenia wstepne, pojawia sie wrazenie: „No to super, przeciez sprawdzilem exists() i canRead() — czyli mozna czytac!” I wtedy Kotlin (i system operacyjny) delikatnie przypominaja: „ha-ha, nie”. Sprawdzenia wstepne mowia tylko, ze w momencie sprawdzania wszystko wygladalo przyzwoicie. A prawdziwa operacja readText() to juz realna proba odczytu danych — i ona nadal moze skonczyc sie wyjatkiem.

Wyobrazmy sobie prosta sytuacje: sprawdziles exists() == true, a milisekunde pozniej plik zostal usuniety (albo przemianowany) przez inny proces/uzytkownika. To nie fantastyka — to normalne zycie na komputerze.

import java.io.File

fun main() {
    val file = File("data/input.txt")

    if (file.exists()) {
        // Miedzy exists() a readText() plik moze zniknac.
        val text = file.readText()
        println("OK: ${text.length}") // jesli sie poszczesci
    }
}

Jesli sie nie poszczesci — dostaniesz wyjatek juz podczas czytania. I to jest normalne: try/catch to nie zamiennik sprawdzen wstepnych, tylko druga bariera, ktora musi stac wokol prawdziwego I/O.

2. Waski try: do srodka — tylko prawdziwe I/O

Najczestszy blad poczatkujacych wyglada tak: „owine wszystko w jeden duzy try, zeby na pewno nie wywalilo”. To jak zalozyc kamizelke kuloodporna na lodowke: ciezko, niewygodnie, a lodowce i tak wszystko jedno. Duzy try sprawia, ze kod robi sie metny: nie wiadomo, co dokladnie moglo sie zepsuc, a w catch czesto probujesz „jakos kontynuowac”, nie rozumiejac, w jakim stanie sa dane.

Antyprzyklad: zbyt szeroki try.

import java.io.File
import java.io.IOException

fun main() {
    val file = File("data/amount.txt")

    try {
        val text = file.readText()
        val amount = text.trim().toInt() // to juz NIE jest I/O, tylko format danych
        println("amount=$amount")
    } catch (e: IOException) {
        println("Nie udalo sie odczytac pliku")
    }
}

Problem jest tu subtelny: toInt() moze rzucic NumberFormatException, a on nie jest IOException. Czyli program i tak sie wywali, ale juz „jakos dziwnie”: przeciez „wszystko owinelismy”. A nie owinelismy wszystkiego — tylko czesc I/O.

Lepiej rozdzielic odpowiedzialnosc: czytanie pliku to I/O i lapie sie jako IOException, a parsowanie tekstu to juz „blad danych/formatu” i powinno zyc osobno (nawet jesli dzis skupiamy sie wlasnie na I/O).

import java.io.File
import java.io.IOException

fun readTextOrNull(file: File): String? =
    try {
        file.readText()
    } catch (e: IOException) {
        null
    }

fun main() {
    val file = File("data/amount.txt")

    val text = readTextOrNull(file) ?: run {
        println("Nie odczytano pliku: ${file.path}")
        return
    }

    val amount = text.trim().toIntOrNull()
    println("amount=$amount") // amount=123 albo amount=null
}

Zauwaz odczucie w kodzie: odczyt jest wydzielony, try jest waski, a dalej spokojnie decydujesz, co zrobic z zawartoscia.

Mala „mapa” odpowiedzialnosci jest przydatna nawet w projektach konsolowych:

flowchart TD
    A[CLI / main] --> B["loadRawText()"]
    B -->|I/O: readText| C[(File)]
    B -->|String? / blad| A
    A --> D[parse / validate]
    D -->|blad formatu| A

3. try jako wyrazenie: zwracamy wartosc, a nie „rozpadamy sie”

Kotlin potrafi cos, co sprawia, ze obsluga bledow jest wyraznie przyjemniejsza: try/catch to wyrazenie, czyli moze zwrocic wartosc. Dzieki temu mozna pisac funkcje z jasnym kontraktem: „jesli sie udalo — zwroce wynik, jesli nie — zwroce wariant zapasowy”. W Kotlin taki styl jest uznawany za idiomatyczny: try zwraca jedna wartosc, catch — inna.

Najprostsza wersja dla odczytu pliku: „tekst albo null”.

import java.io.File
import java.io.IOException

fun readTextOrNull(file: File): String? = try {
    file.readText()
} catch (e: IOException) {
    null
}

fun main() {
    val file = File("data/input.txt")

    val text = readTextOrNull(file)
    println(text?.take(20) ?: "NO DATA") // na przyklad: NO DATA
}

Dokladnie tak samo dla zapisu mozna wybrac kontrakt „sukces/porazka”.

import java.io.File
import java.io.IOException

fun writeTextSafely(file: File, text: String): Boolean = try {
    file.writeText(text)
    true
} catch (e: IOException) {
    false
}

fun main() {
    val ok = writeTextSafely(File("data/out/report.txt"), "Hello!\n")
    println("written=$ok") // written=true albo written=false
}

To wyglada na proste, ale dyscyplinuje program: zamiast liczyc „no, nie wywali sie”, z gory projektujesz: co zwracamy w razie problemow.

4. Kontrakt funkcji przy I/O: T?, Boolean albo przepuszczenie wyjatku

Kiedy stawiasz try/catch, zawsze podejmujesz decyzje: co zobaczy kod wywolujacy? W edukacyjnych aplikacjach konsolowych zwykle wystarcza jeden z trzech kontraktow: zwrocic T?, zwrocic Boolean albo nie lapac wyjatku tutaj i pozwolic mu poleciec wyzej (czasem to tez jest sensowna strategia).

Warto miec to porownanie w glowie jako tabelke:

Kontrakt funkcji Jak wyglada Kiedy wygodne Cena bledu
T?
String?, List<String>?
Gdy brak wyniku to normalna galaz logiki Trzeba pamietac o obsluzeniu
null
Boolean
true/false
Gdy dane nie sa potrzebne, liczy sie tylko „czy sie udalo” Slabsza diagnostyka: dlaczego
false
?
Przepuscic wyjatek bez try/catch (albo throw dalej) Gdy bez pliku program nie ma sensu Program moze sie wywalic, jesli wyzej tego nie obsluza

Wazne: nie wybieramy „dobrze raz na zawsze”. Wybieramy, co jest wygodniejsze w twojej aplikacji i w tym miejscu kodu.

Na przyklad w naszej edukacyjnej aplikacji konsolowej niech bedzie prosta „skarbonka wydatkow”: przechowujemy wydatki w pliku tekstowym i pokazujemy krotki raport. Jesli pliku nie dalo sie odczytac, mozemy pokazac zrozumialy komunikat i wystartowac z pusta lista — to swietny kandydat na kontrakt List<String>?.

import java.io.File
import java.io.IOException

fun readLinesOrNull(file: File): List<String>? = try {
    file.readLines()
} catch (e: IOException) {
    null
}

fun main() {
    val file = File("data/expenses.txt")
    val lines = readLinesOrNull(file) ?: emptyList()

    println("loaded=${lines.size}") // loaded=0, jesli pliku nie ma / nie da sie czytac
}

A zapis raportu mozna zrobic jako Boolean: nie zapisalo sie — po prostu poinformujemy.

5. Komunikaty o bledach: dla uzytkownika i dla ciebie

Komunikat o bledzie to nie dzielo artystyczne i nie „cos poszlo nie tak”. Dobry komunikat odpowiada co najmniej na trzy pytania: co chcielismy zrobic, z czym dokladnie (sciezka) i jaki to typ problemu. Nawet bez systemu logowania to juz bardzo pomaga.

Warto miec maly formatter komunikatow dla I/O. Nie musi byc „madry” — ma byc stabilny i taki sam w calej aplikacji.

import java.io.File
import java.io.IOException

fun ioErrorMessage(action: String, file: File, e: IOException): String {
    val type = e::class.simpleName ?: "IOException"
    val detail = e.message ?: "no details"
    return "Nie udalo sie $action: ${file.path}. Przyczyna: $type: $detail"
}

I uzywac go w catch:

import java.io.File
import java.io.IOException

fun readTextOrNullVerbose(file: File): String? = try {
    file.readText()
} catch (e: IOException) {
    println(ioErrorMessage("odczytac plik", file, e))
    null
}

fun main() {
    val text = readTextOrNullVerbose(File("data/input.txt"))
    println(text?.length ?: "no text") // no text
}

Tak, wypisujemy wprost do konsoli — i to jest OK dla edukacyjnego CLI. W powazniejszych projektach rozdzielilbys „komunikat dla uzytkownika” i „diagnostyke do logow”, ale swiadomie nie wchodzimy dzis w logowanie.

Kilka catch: kolejnosc i sens podzialu

Czasem kusi, by lapac wszystko jednym catch (IOException). To w porzadku, ale czesto warto rozroznic przynajmniej jeden szczegolny przypadek: „plik nie znaleziony / nie da sie otworzyc”, czyli FileNotFoundException. Wtedy mozesz dac bardziej ludzki tekst: „sprawdz sciezke” zamiast „oj, I/O”.

Zasada w Kotlin (i w ogole na JVM): najpierw lap bardziej szczegolne, potem bardziej ogolne, inaczej szczegolne nigdy nie zadziala.

import java.io.File
import java.io.FileNotFoundException
import java.io.IOException

fun main() {
    val file = File("data/input.txt")

    try {
        val text = file.readText()
        println("OK: ${text.length}") // OK: ...
    } catch (e: FileNotFoundException) {
        println("Plik nie znaleziony albo nie da sie go otworzyc: ${file.path}")
    } catch (e: IOException) {
        println("Blad wejscia/wyjscia podczas odczytu: ${file.path}")
        println("Szczegoly: ${e.message}")
    }
}

Wazne, zeby nie przekombinowac: rozdzielilismy tylko najczestszy przypadek, a cala reszte zostawilismy pod IOException.

6. Gdzie stawiac try/catch: granica odpowiedzialnosci

Bardzo kusi, by lapac wyjatki w main, bo „tam wszystko widac”. Ale wraz z rozrostem aplikacji zamienia sie to w ogromny kombajn: main i czyta wejscie, i parsuje komendy, i pracuje na danych, i jeszcze lapie wszystkie mozliwe problemy. Dobry styl to lapanie I/O tam, gdzie faktycznie zachodzi, i zwracanie na zewnatrz czytelnego rezultatu.

Dla naszego trackera wydatkow oznacza to: funkcje „warstwy storage” same owijaja readText()/writeText() w try/catch, a main dostaje albo dane, albo sygnal „nie udalo sie”.

Schemat wychodzi prosty:

flowchart TD
    M[main: polecenia uzytkownika] --> S[storage: read/write file]
    S -->|List⟨String⟩? / Boolean| M
    S -->|IOException wewnatrz| S

Przyklad mini-warstwy storage (bez „magii”, wszystko krotko i jawnie):

import java.io.File
import java.io.IOException

fun loadExpensesOrNull(file: File): List<String>? = try {
    file.readLines()
} catch (e: IOException) {
    null
}

fun saveExpenses(file: File, lines: List<String>): Boolean = try {
    file.writeText(lines.joinToString(separator = "\n"))
    true
} catch (e: IOException) {
    false
}

I uzycie w main bez wrazenia, ze sterujesz elektrownia atomowa:

import java.io.File

fun main() {
    val file = File("data/expenses.txt")

    val lines = loadExpensesOrNull(file)
    if (lines == null) {
        println("Nie udalo sie zaladowac wydatkow, startujemy z pusta lista.")
    } else {
        println("Zaladowano wpisow: ${lines.size}") // na przyklad: Zaladowano wpisow: 12
    }
}

Psychologicznie to wazne: main nie musi wiedziec, jak dokladnie system operacyjny odmowil odczytu. main ma podjac decyzje: kontynuowac z pustymi danymi, poprosic uzytkownika o poprawienie sciezki, zakonczyc dzialanie — itd.

7. finally i use {}: kiedy sa potrzebne

Slowo finally czesto brzmi jak „sekretna technika Jedi”. W praktyce jest potrzebne dokladnie do jednego: zagwarantowac, ze pewien fragment kodu wykona sie na koncu, nawet jesli wewnatrz try wydarzy sie blad. W zadaniach plikowych to szczegolnie wazne do zamykania zasobow, jesli recznie otwierasz strumienie/czytniki.

Ale Kotlin daje jeszcze wygodniejszy styl: use {}, ktory automatycznie zamyka zasob (w przyblizeniu jak finally, tylko czytelniej). To uznaje sie za idiomatyczne podejscie dla AutoCloseable.

Nie bedziemy dzis wchodzic gleboko w strumienie, ale maly przyklad jest pomocny, zeby zobaczyc granice: readText() nie wymaga use, a bufferedReader() — juz tak.

import java.io.File
import java.io.IOException

fun readFirstLineOrNull(file: File): String? = try {
    file.bufferedReader().use { reader ->
        reader.readLine()
    }
} catch (e: IOException) {
    null
}

fun main() {
    val first = readFirstLineOrNull(File("data/input.txt"))
    println(first ?: "<empty>") // na przyklad: <empty>
}

Zapamietaj prosta zasade: jesli widzisz ...Reader(), ...Writer(), InputStream, OutputStream — prawie zawsze obok powinno byc use {} (albo przynajmniej finally). Jesli uzywasz „gotowych” funkcji typu readText()/writeText(), one same wewnatrz otwieraja i zamykaja zasoby, wiec nie musisz recznie pisac finally.

Maly niuans Kotlin 2.x: kompilator lepiej sledzi informacje smart-cast przez catch/finally, wiec w niektorych scenariuszach trafniej podpowiada, gdzie zmienna znow staje sie nullable. To dotyczy ulepszen obslugi wyjatkow w K2.

8. Typowe bledy przy try/catch wokol operacji plikowych

Blad nr 1: ogromny try, w ktorym jest i odczyt pliku, i parsowanie danych, i polowa logiki biznesowej.
Taki kod jest nieprzyjemny, bo tracisz zrozumienie przyczyny awarii: to I/O, format, czy twoja logika? Do tego w catch pojawia sie pokusa „dokonczyc cos”, chociaz dane mogly sie nie zaladowac. Lekarstwo jest proste: wynos I/O do malych funkcji i trzymaj try wasko — wokol readText()/writeText() i bliskich operacji.

Blad nr 2: „tlumic” wyjatek po cichu.
Zwrocic null albo false — to OK, ale jesli nie komunikujesz kompletnie nic, to przy realnym problemie bedziesz debugowac „czemu pusto” zamiast „czemu nie dalo sie odczytac”. Nawet w edukacyjnym CLI warto choc raz wypisac: akcja, sciezka, e.message. Inaczej diagnostyka zamienia sie w wrozenie z fusow.

Blad nr 3: lapac Exception „na wszelki wypadek”.
Wyglada to jak dbalosc o stabilnosc, ale czesciej jest to zamiatanie pod dywan: zaczynasz mieszac bledy I/O, bledy formatu i wlasne bugi. W efekcie program „jakby dziala”, ale robi dziwne rzeczy. Dla plikow trzymaj fokus na IOException (i czasem FileNotFoundException jako szczegolny przypadek), a pozostalych bledow nie ukrywaj bez powodu.

Blad nr 4: zwracac Boolean, ale nie dawac zadnej informacji „dlaczego false”.
Boolean jest wygodny, gdy liczy sie tylko fakt powodzenia. Ale jesli czasem jednak potrzebujesz znac powod, mozna (w ramach dzisiejszego dnia) przynajmniej wypisac komunikat wewnatrz catch, albo zwracac null/String? z tekstem bledu. Najwazniejsze — nie tworzyc API, w ktorym false oznacza dziesiec roznych przyczyn, a nigdzie nie da sie sprawdzic, ktora to byla.

Blad nr 5: zla kolejnosc catch.
Jesli najpierw napiszesz catch (e: IOException), a potem catch (e: FileNotFoundException), to drugi blok stanie sie nieosiagalny, bo FileNotFoundException to szczegolny przypadek IOException. Kolejnosc ma byc „najpierw szczegolowe, potem ogolne”, jak w klasycznych zasadach obslugi wyjatkow.

Blad nr 6: kontynuowac uzywanie danych po nieudanym odczycie.
To subtelny bug: zlapales wyjatek, wypisales „nie udalo sie”, ale zmienna zostala np. pustym stringiem, i dalej parsujesz ja tak, jakby to byla zawartosc pliku. W efekcie dostajesz wtorna awarie (np. NumberFormatException) i mylisz sie, co tak naprawde sie zepsulo. Jesli odczyt sie nie udal — zwracaj null/false i wyraznie przerywaj ta sciezke wykonania.

Blad nr 7: traktowac canRead()/canWrite() jako gwarancje.
Te sprawdzenia sa przydatne, ale niczego nie obiecuja, bo uprawnienia moga sie zmienic, plik moze byc zajety, system plikow moze „odpasc” itd. Dlatego poprawne polaczenie wyglada tak: sprawdzenia wstepne poprawiaja komunikaty i wczesne odrzucenie, a try/catch zabezpiecza realna operacje.

1
Zadanie
Kotlin SELF, poziom 45, lekcja 2
Niedostępne
Podgląd notatki
Podgląd notatki
1
Zadanie
Kotlin SELF, poziom 45, lekcja 2
Niedostępne
Niezawodny zapis
Niezawodny zapis
1
Zadanie
Kotlin SELF, poziom 45, lekcja 2
Niedostępne
Komunikat do logu
Komunikat do logu
1
Zadanie
Kotlin SELF, poziom 45, lekcja 2
Niedostępne
Suma z pliku
Suma z pliku
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION