Go dla początkujących - Część 4: Obsługa błędów

1/30/2025 Kurs Go

Mateusz Kędziora

image

Witaj w kolejnym wpisie z serii o języku Go! Dziś zmierzymy się z tematem, który dla wielu początkujących programistów bywa źródłem frustracji, ale który jest absolutnie kluczowy dla tworzenia stabilnego i niezawodnego oprogramowania. Mówimy o obsłudze błędów.

W Go nie ma wyjątków, tak jak w wielu innych językach. Zamiast tego, Go kładzie duży nacisk na jawne sprawdzanie i obsługę potencjalnych problemów, które mogą wystąpić podczas wykonywania programu. To podejście, choć na początku może wydawać się nieco bardziej pracochłonne, w dłuższej perspektywie pozwala na lepszą kontrolę nad przepływem programu i minimalizuje ryzyko nieoczekiwanych awarii.

Dlaczego obsługa błędów jest tak ważna?

Wyobraź sobie, że Twój program ma za zadanie odczytać dane z pliku, przetworzyć je i zapisać w nowym miejscu. Co się stanie, jeśli plik nie istnieje? A co, jeśli brakuje uprawnień do zapisu? Bez odpowiedniej obsługi błędów program może zakończyć swoje działanie w niekontrolowany sposób, powodując utratę danych, a w najgorszym przypadku – awarię.

Dobra obsługa błędów to nie tylko zapobieganie crashom, ale także:

  • Utrzymanie stabilności: Programy, które potrafią poradzić sobie z nieoczekiwanymi sytuacjami, są bardziej niezawodne.
  • Łatwiejsze debugowanie: Jasne komunikaty o błędach ułatwiają zlokalizowanie źródła problemu.
  • Lepsze doświadczenie użytkownika: Użytkownik nie jest zaskakiwany nieczytelnymi komunikatami lub nagłym zamknięciem aplikacji.

Interfejs error – Podstawa obsługi błędów w Go

W Go błędy są reprezentowane przez interfejs error, który jest zdefiniowany w pakiecie builtin i wygląda następująco:

type error interface {
    Error() string
}

Interfejs error wymaga, aby każdy typ, który chce być traktowany jako błąd, implementował metodę Error() string, która zwraca tekstowy opis błędu.

Jak zwracać błędy?

W Go funkcje często zwracają dwa rezultaty: właściwą wartość i błąd. Jeśli operacja zakończyła się powodzeniem, zwracany błąd ma wartość nil (pusty wskaźnik). W przeciwnym wypadku zwracany jest obiekt implementujący interfejs error. Przykład:

package main

import (
	"fmt"
	"os"
)

func readFile(filename string) (string, error) {
	content, err := os.ReadFile(filename)
	if err != nil {
		return "", err
	}
	return string(content), nil
}

func main() {
	content, err := readFile("myfile.txt")
	if err != nil {
		fmt.Println("Wystąpił błąd:", err)
		return
	}
	fmt.Println("Zawartość pliku:", content)
}

W tym przykładzie funkcja readFile próbuje odczytać zawartość pliku. Jeśli operacja się nie powiedzie (np. plik nie istnieje), funkcja zwraca błąd. W funkcji main sprawdzamy, czy funkcja readFile zwróciła błąd. Jeśli tak, wypisujemy go na konsoli i kończymy program.

Sprawdzanie błędów – Zasadą numer jeden

W Go, sprawdzanie błędów po każdej operacji, która może potencjalnie zwrócić błąd, jest kluczowe. Jest to tzw. “idiom Go” – styl programowania, który preferuje jawność i dokładność.

file, err := os.Open("config.txt")
if err != nil {
    fmt.Println("Nie udało się otworzyć pliku:", err)
    return
}
defer file.Close() //Pamiętaj o zamknięciu pliku!

// Operacje na pliku
_, err = file.Read(...)
if err != nil {
    fmt.Println("Błąd podczas odczytu:", err)
    return
}

Funkcja panic i odzyskiwanie z błędów za pomocą recover

W sytuacjach, gdy wystąpił błąd, z którego program nie może się samodzielnie podnieść, możemy użyć funkcji panic. Funkcja panic natychmiast przerywa wykonanie programu i uruchamia proces zwijania stosu (ang. stack unwinding).

func criticalFunction() {
    // ...
    if somethingBadHappened {
        panic("Krytyczny błąd! Program nie może dalej działać.")
    }
    // ...
}

W normalnych sytuacjach panika kończy działanie programu. Ale Go oferuje mechanizm odzyskiwania z paniki za pomocą funkcji recover. Funkcja recover działa tylko w ramach funkcji opóźnionej (ang. deferred function) za pomocą słowa kluczowego defer. Dzięki temu możemy spróbować obsłużyć panikę w sposób kontrolowany.

func handlePanic() {
    if r := recover(); r != nil {
        fmt.Println("Odzyskano po panice:", r)
        // Możemy tutaj podjąć działania, np. zapisać logi
    }
}

func riskyFunction() {
    defer handlePanic()

    // ...
    if somethingBadHappened {
       panic("Ups, znowu panika!")
    }
    // ...
}

func main(){
    riskyFunction()
    fmt.Println("Program kontynuuje działanie po obsłużeniu paniki")
}

W tym przykładzie funkcja riskyFunction wywołuje panikę, ale funkcja handlePanic, która jest uruchamiana w trybie opóźnionym, przechwytuje panikę za pomocą recover. Dzięki temu program nie kończy nagle swojego działania i może kontynuować prace.

Kiedy używać panic i recover?

  • panic: Używaj tylko w przypadkach, gdy wystąpił nieodwracalny błąd, który uniemożliwia dalsze działanie programu. Najczęściej dotyczy to błędów, które nie powinny się zdarzyć w normalnych okolicznościach, np. błąd programisty.
  • recover: Używaj do odzyskiwania po panice w kontrolowany sposób. Funkcja recover powinna być używana głównie w funkcjach, które działają jako “bariery” dla potencjalnych panik.

Tworzenie własnych typów błędów

Czasami standardowy tekstowy opis błędu nie jest wystarczający. Chcemy mieć bardziej szczegółowe informacje o błędzie. W Go możemy tworzyć własne typy błędów, które implementują interfejs error.

package main

import (
	"fmt"
	"time"
)

type MyError struct {
	Message   string
	Timestamp time.Time
}

func (e MyError) Error() string {
    return fmt.Sprintf("Błąd: %s, wystąpił o %v", e.Message, e.Timestamp)
}


func validateData(data string) error{
	if len(data) < 10{
		return MyError{Message: "Dane są zbyt krótkie", Timestamp: time.Now()}
	}
	return nil
}

func main() {
	err := validateData("abc")
	if err != nil {
		fmt.Println(err) //Wypisze opis błędu ze struktury MyError
        myErr, ok := err.(MyError)
        if ok {
             fmt.Println("Dodatkowa informacja o błędzie: ", myErr.Timestamp)
        }
	}

	err2 := validateData("abcdefghijk")
    if err2 != nil {
        fmt.Println("Błąd 2: ", err2)
    }
    fmt.Println("Koniec")

}

W tym przykładzie zdefiniowaliśmy strukturę MyError, która implementuje interfejs error. Teraz możemy zwracać błędy zawierające dodatkowe informacje, takie jak czas wystąpienia błędu. W bloku main sprawdzamy, czy error jest typu MyError, używając tzw. type assertion, aby móc pobrać z błędu dokładną strukturę danych.

Najlepsze praktyki obsługi błędów w Go

  • Sprawdzaj błędy po każdej operacji: Nie ignoruj zwracanych błędów.
  • Zwracaj błędy, gdy coś poszło nie tak: Pozwól wywołującej funkcji zdecydować, jak obsłużyć błąd.
  • Używaj prostych opisów błędów: Opis błędu powinien być zrozumiały i pomóc w identyfikacji problemu.
  • Twórz własne typy błędów: Dodawaj szczegółowe informacje o błędach, gdy jest to potrzebne.
  • Używaj panic tylko w sytuacjach krytycznych: Nie nadużywaj panic.
  • Używaj recover do odzyskiwania po panice w kontrolowany sposób: Pamiętaj o defer.
  • Loguj błędy: Zapisuj informacje o błędach w logach, aby łatwiej diagnozować problemy.
  • Nie rzucaj błędami w górę bezmyślnie: Spróbuj obsłużyć błąd, gdy jest to możliwe w danym kontekście.
  • Pamiętaj o zamykaniu zasobów: Zawsze zamykaj otwarte pliki i połączenia. Użyj defer, aby zagwarantować zamknięcie.

Praca domowa

  1. Napisz program, który odczytuje plik konfiguracyjny (np. config.json) zawierający ustawienia aplikacji. W przypadku niepowodzenia odczytu lub problemów z parsowaniem JSON, program powinien zwrócić własny, szczegółowy typ błędu.
  2. Rozbuduj program z poprzedniego zadania o mechanizm obsługi paniki. W przypadku nieoczekiwanego błędu, program powinien wypisać komunikat o błędzie i zapisać go w logu.
  3. Dodaj funkcję, która dzieli dwie liczby. Funkcja powinna zwracać błąd, gdy próbujemy podzielić przez zero. Użyj swojego typu błędu z dodatkową informacją o tym jaka liczba jest dzielnikiem (dzielenie przez zero).

Podsumowanie

Obsługa błędów jest fundamentalnym elementem każdego solidnego programu. W Go, dzięki jawnemu sprawdzaniu błędów, mamy pełną kontrolę nad tym, jak nasza aplikacja zachowuje się w sytuacjach awaryjnych. Nie ignoruj błędów, zadbaj o ich dokładne sprawdzanie i obsługę, a Twój kod będzie bardziej niezawodny i łatwiejszy w utrzymaniu.

Zachęcam Cię do eksperymentowania z kodem z tego artykułu i przeczytania innych wpisów na blogu, gdzie omawiamy kolejne zagadnienia języka Go. Pamiętaj, praktyka czyni mistrza, więc nie bój się kodować i próbować nowych rzeczy. Do zobaczenia w kolejnym artykule!

Polecane artykuły