Привет, Хаброжители! У нас отличные новости для всех, кто хочет освоить Go или систематизировать свои знания. Мы открыли предзаказ на новую книгу Станислава Чернышева «Основы Go». Чтобы вы могли оценить подачу материала и глубину погружения, мы делимся с вами эксклюзивным отрывком.

Первая версия игры была написана с использованием функций. Теперь же мы рассмотрим, как изменить кодовую составляющую проекта, распределив необходимый функционал по структурам, связанным с предметной областью. Если не брать в расчет несколько перечислений, то таких структур всего три:

  • Board;

  • Player;

  • Game.

Рефакторинг проекта начнем с внесения изменений в пакет game, добавив в него следующие файлы:

tic_tac_toe_v2
├── game/
│    ├── board.go
│    ├── board_cell_type.go
│    ├── game_state.go
│    ├── game.go
│    └── player.go
├── go.mod
└── main.go

В файл board_cell_type.go переместим перечисления, отвечающие за тип символа в ячейке игрового поля:

// baseURL/part_3/tic_tac_toe_v2/game/board_cell_type.go
package game

type BoardField int

// фигуры в клетке поля
const (
    empty BoardField = iota
    cross
    nought
)

А в game_state.go — перечисления, отвечающие за текущее состояние игрового процесса:

// baseURL/part_3/tic_tac_toe_v2/game/game_state.go
package game

type GameState int

// состояние игрового процесса
const (
    playing GameState = iota
    draw
    crossWin
    noughtWin
    quit
)

В файле board.go объявим структуру Board, возложив на нее обязанность отвечать за все действия, связанные с игровым полем. По сути, мы переместим в этот файл все функции, ранее работавшие с игровой доской, сделав их методами структуры:

// baseURL/part_3/tic_tac_toe_v2/game/board.go
package game

import (
    "fmt"
)

const (
    BoardDefaultSize int = 3
    BoardMinSize int = 3
    BoardMaxSize int = 9
)

type Board struct {
    board [][]BoardField
    size int
}

func NewBoard(size int) *Board {
    board := make([][]BoardField, size)
    for i := range board {
        board[i] = make([]BoardField, size)
    }
    return &Board{board: board, size: size}
}

// Отображение игрового поля
func (b *Board) printBoard() {
    fmt.Print(" ")
    for i := range b.size {
        fmt.Printf("%d ", i+1)
    }
    fmt.Println()
    for i := range b.size {
        fmt.Printf("%d ", i+1)
        for j := range b.size {
            switch b.board[i][j] {
            case empty:
                fmt.Print(". ")
            case cross:
                fmt.Print("X ")
            case nought:
                fmt.Print("O ")
            }
        }
        fmt.Println()
    }
}

// Проверка возможности выполнения хода
func (b *Board) makeMove(x, y int) bool {
    return b.board[x][y] = = empty
}
func (b *Board) setSymbol(x, y int, player BoardField) bool {
    if b.makeMove(x, y) {
        b.board[x][y] = player
        return true
    }
    return false
}

// Проверка выигрыша
func (b *Board) checkWin(player BoardField) bool {
    // Проверка строк и столбцов
    for i := range b.size {
        rowWin, colWin := true, true
        for j := range b.size {
            if b.board[i][j] ! = player {
                rowWin = false
            }
            if b.board[j][i] ! = player {
                colWin = false
            }
        }
        if rowWin || colWin {
            return true
        }
    }

    // Главная диагональ
    mainDiag := true
    for i := range b.size {
        if b.board[i][i] ! = player {
            mainDiag = false
            break
        }
    }
    if mainDiag {
        return true
    }

    // Побочная диагональ
    antiDiag := true
    for i := range b.size {
        if b.board[i][b.size-i-1] ! = player {
            antiDiag = false
            break
        }
    }
    return antiDiag
}

// Проверка на ничью
func (b *Board) checkDraw() bool {
    for i := range b.size {
        for j := range b.size {
            if b.board[i][j] = = empty {
                return false
            }
        }
    }
    return true
}

Все, что касается работы с игроком, поместим в структуру Player:

// baseURL/part_3/tic_tac_toe_v2/game/player.go
package game

type Player struct {
    figure BoardField
}

func NewPlayer() *Player {
    return &Player{figure: cross}
}

func (p *Player) switchPlayer() {
    if p.figure = = cross {
        p.figure = nought
    } else {
        p.figure = cross
    }
}

func (p *Player) getSymbol() string {
    if p.figure = = cross {
        return "X"
    }
    return "O"
}

Структура Game будет принимать в конструктор экземпляр игрового поля, поток ввода данных с клавиатуры и игрока, делающего первый ход, и отвечать за игровой процесс (цикл), где игроки поочередно должны вводить через пробел номер строки и столбца, на пересечении которых установится их символ, после чего выполняется проверка на завершение текущей игровой сессии:

// baseURL/part_3/tic_tac_toe_v2/game/game.go
package game

import (
    "bufio"
    "fmt"
    "strconv"
    "strings"
)

type Game struct {
    board *Board
    player *Player
    reader *bufio.Reader
    state GameState
}

func NewGame(board Board, player Player, reader *bufio.Reader) *Game {
    return &Game{
        board: &board,
        player: &player,
        reader: reader,
        state: playing,
    }
}

func (g *Game) updateState() {
    if g.board.checkWin(g.player.figure) {
        if g.player.figure = = cross {
            g.state = crossWin
        } else {
            g.state = noughtWin
        }
    } else if g.board.checkDraw() {
        g.state = draw
    }
}

// Игровой цикл
func (g *Game) Play() {
    for g.state = = playing {
        g.board.printBoard()
        fmt.Printf(
            "%s's turn. Enter row and column (e.g. 1 2): ",
            g.player.getSymbol())

        input, err := g.reader.ReadString('\n')
        if err ! = nil {
            fmt.Println("Invalid input. Please try again.")
            continue
        }

        input = strings.TrimSpace(input)
        if input = = "q" {
            g.state = quit
            break
        }

        parts := strings.Fields(input)
        if len(parts) ! = 2 {
            fmt.Println("Invalid input. Please try again.")
            continue
        }

        row, err1 := strconv.Atoi(parts[0])
        col, err2 := strconv.Atoi(parts[1])
        if err1 ! = nil || err2 ! = nil ||
            row < 1 || col < 1 || row > g.board.size ||
            col > g.board.size {
            fmt.Println("Invalid input. Please try again.")
            continue
        }
        if g.board.setSymbol(row-1, col-1, g.player.figure) {
            g.updateState()
            g.player.switchPlayer()

        } else {
            fmt.Println("This cell is already occupied!")
        }
    }

    g.board.printBoard()

    if g.state = = crossWin {
        fmt.Println("X wins!")
    } else if g.state = = noughtWin {
        fmt.Println("O wins!")
    } else if g.state = = draw {
        fmt.Println("It's a draw!")
    } else {
        fmt.Println("Game over!")
    }
}

Теперь откройте файл main.go в основной директории проекта tic_tac_toe и внесите в него следующие изменения:

// baseURL/part_3/tic_tac_toe_v2/main.go
package main

import (
    "bufio"
    "fmt"
    "os"
    "strconv"
    "strings"
    "tic-tac-toe/game"
)

func main() {
    reader := bufio.NewReader(os.Stdin)
    boardSize := 0
    for {
        fmt.Print("Enter the size of the board (3-9): ")
        input, err := reader.ReadString('\n')
        if err ! = nil {
            fmt.Println("Error reading input.")
            continue
        }
        input = strings.TrimSpace(input)
        boardSize, err = strconv.Atoi(input)
        if err ! = nil {
            // Использовать предыдущий размер по умолчанию
            boardSize = game.BoardDefaultSize
        }
        if boardSize < game.BoardMinSize ||
            boardSize > game.BoardMaxSize {
            fmt.Println("Invalid board size.")
        } else {
            break
        }
    }

    board := game.NewBoard(boardSize)
    player := game.NewPlayer()
    game := game.NewGame(*board, *player, reader)
    game.Play()
}

Как и в первой версии, после запуска игры приложение попросит пользователя ввести размер игрового поля и предоставит игрокам, начиная с Х, возможность поочередно делать ходы до победы одного из них либо наступления патовой ситуации (ничьей):

Enter the size of the board (3-9): 4
  1 2 3 4
1 . . . .
2 . . . .
3 . . . .
4 . . . .
X's turn. Enter row and column (e.g. 1 2): 1 1
  1 2 3 4
1 X . . .
2 . . . .
3 . . . .
4 . . . .
O's turn. Enter row and column (e.g. 1 2): 3 4
  1 2 3 4
1 X . . .
2 . . . .
3 . . . O
4 . . . .
X's turn. Enter row and column (e.g. 1 2): 2 1
  1 2 3 4
1 X . . .
2 X . . .
3 . . . O
4 . . . .
O's turn. Enter row and column (e.g. 1 2): 3 3
  1 2 3 4
1 X . . .
2 X . . .
3 . . O O
4 . . . .
X's turn. Enter row and column (e.g. 1 2): 3 1
  1 2 3 4
1 X . . .
2 X . . .
3 X . O O
4 . . . .
O's turn. Enter row and column (e.g. 1 2): 1 2
  1 2 3 4
1 X O . .
2 X . . .
3 X . O O
4 . . . .
X's turn. Enter row and column (e.g. 1 2): 4 1
  1 2 3 4
1 X O . .
2 X . . .
3 X . O O
4 X . . .
X wins!

В ходе рефакторинга мы перевели игру, которая была написана в процедурном стиле, в объектно-ориентированную парадигму. Для этого мы выполнили декомпозицию и выделили основные структуры (классы), распределив между ними код первой версии игры; при этом в сам код вносились минимальные изменения.

Задания на доработку проекта

В конце главы 5 мы добавим возможность сохранения и загрузки игровой сессии. А пока предлагаю выполнить следующие задания.

  1. Добавьте еще одного игрока (символ Z), ограничив минимальный размер поля: 5 × 5. Для победы любому игроку достаточно провести линию (горизонталь, вертикаль, диагональ), состоящую из четырех символов.

  2. После завершения игры должна быть возможность без выхода из приложения запустить новую игру.

  3. Покройте код приложения тестами.

  4. Добавьте команду, которая во время игры сбрасывает состояние игрового поля — удаляет расставленные ранее крестики и нолики.

Резюме

В этой главе мы рассмотрели написание кода в объектно-ориентированном стиле и механизмы, которые для этого используются. Будьте внимательны при работе с интерфейсами и не пытайтесь покрыть все методы структуры одним интерфейсом. Очень часто доступ к той или иной функциональности приложения необходимо разграничить в зависимости от типа пользователя; единый интерфейс для этого не подойдет, так как станет потенциальным источником проблем.

Кроме того, отслеживайте, с какой буквы начинается имя структуры, ее полей и методов работы с ней. Если функциональность не нужно экспортировать в другой пакет, то используйте сокрытие и начинайте имена с прописной буквы.

В Go нет конструкторов, поэтому рекомендуется предоставлять пользователю вашей кодовой базы конструкторную функцию (одну или несколько), отвечающую за создание экземпляра структуры, объявленной в пакете.

Помните: грамотное использование интерфейсов позволяет вывести процесс разработки на новый уровень абстракции, придавая коду гибкость и улучшая его читаемость. А дженерики позволяют избежать дублирования кода и таким образом решить проблему, с которой Go-разработчикам приходилось бороться до версии 1.18.

В этой главе мы продолжили работать с игрой «Крестики-нолики»: снова выполнили рефакторинг, а также перевели код в объектно-ориентированную парадигму (v.2), сохранив все возможности предыдущей версии. Теперь код распределен по отдельным файлам и организован в виде структур, отражающих предметную область:

  • Board — отвечает за состояние игрового поля, его инициализацию, проверку ходов, выигрыша и ничьей;

  • Player — управляет текущим игроком, его фигурой (X или O) и сменой хода;

  • Game — координирует игровой процесс: принимает ввод от пользователя, взаимодействует с доской и игроком, обновляет состояние игры.

ОБРАБОТКА ОШИБОК И ПАНИК В GO

Так или иначе, в вашем коде будут встречаться ошибки. Одни могут привести к падению приложения, другие же — изменить данные так, что те потеряют инвариантность. Обработка ошибок — один из самых важных процессов при проектировании программного обеспечения, и разработчик ни в коем случае не должен его пропускать, надеясь на собственную удачливость.

Go отличается от других языков программирования, таких как Python, Java или С++, тем, что в нем нет исключений и, соответственно, конструкции try…catch для их обработки. Исключения позволяют в случае ошибки в вычислениях или логике работы программы сразу же перейти к ее обработке, при этом отменяя все вызовы функций, которые начались до того, как был совершен вход в этот обработчик. Исключения можно рассматривать как некий структурированный «безусловный переход». Такое поведение обеспечивает согласованный способ реагирования на необычные события, возникающие при работе программы, и позволяет не вводить дополнительные проверки на коды отработки вызываемых функций.

В случае с Go обработка ошибок напоминает подход, которым разработчики пользовались еще лет тридцать назад! А именно — возврат статуса выполнения функции, от результата которого зависело, как продолжать работать с полученными данными: считать их ошибочными и обрабатывать эту ситуацию или продолжать работать с ними, так как возвращенное функцией значение соответствовало тому, что она завершилась корректно.

Для возврата ошибок выполнения функций или методов в Go используется тип error со следующим интерфейсом:

type error interface {
    Error() string
}

Такой тип ошибок можно обработать, и программа продолжит функционировать. В то же самое время существуют ошибки, которые невозможно было предвидеть. К этому типу в Go относится panic (паника). Ошибки такого рода приводят к самопроизвольному завершению приложения. Чаще всего к панике приводит обращение по несуществующему индексу среза, попытка работы с указателем, хотя там хранится nil, неправильное использование мьютексов, попытка работы с закрытым файлом и многое другое.

В этой главе мы рассмотрим, как работать с механизмом Go для обработки ошибок и паник. Некоторые разработчики считают, что он слишком утомителен или не соответствует духу времени, но это просто инструмент, которым нужно уметь пользоваться.

Паника (panic) в Go

Рассмотрим классический вариант паники, с которым начинающие разработчики сталкиваются чаще всего, а именно — обращение к несуществующему элементу среза:

// baseURL/part_4/4.1/1.go
package main

import "fmt"

func main() {
    names := []string{
        "Alex",
        "Max",
        "German",
    }
  
    fmt.Printf("My name is %v", names[3])
}

При попытке запустить этот код в приложении возникнет паническая ситуация, которая приведет к его завершению (рис. 4.1).

Рис. 4.1. Ошибка времени выполнения
Рис. 4.1. Ошибка времени выполнения

В описании паники присутствует подсказка с указанием причины: panic: runtime error: index out of range [3] with length 3. У среза names нет элемента, хранящегося по индексу 3. Индексация в массивах и срезах начинается с 0, поэтому максимальный индекс, по которому можно обратиться к элементу среза, равен 2. И поскольку у среды выполнения нет другого варианта, кроме как экстренно завершить работу приложения, то она так и поступает. Но при этом среда дает подсказку о том, в какой строчке кода какого файла проекта возникла ошибка: e:/code/golang/hello/main.go:12. В моем случае это строка 12 файла main.go. Такой вывод информации называется трассировкой стека.

Следующий наиболее частый случай наступления паники — обращение к экземпляру какого-нибудь объекта (полю структуры или методу) через указатель, когда на самом деле он хранит не адрес, а значение nil:

// baseURL/part_4/4.1/2.go
package main

import "fmt"

type Person struct {
    name string
    age uint8
}

func (p *Person) getName() string {
    return p.name
}

func (p *Person) getAge() uint8 {
    return p.age
}

func (p *Person) incAge() {
    p.age+ +
}

func main() {
    alex := &Person{
        name: "Alex",
        age: 26,
    }
    fmt.Printf("Person data: %+v\n", alex)
    alex.incAge()
    fmt.Printf("Person data: %+v\n", alex)
    alex = nil
    alex.incAge()
}
/*
Person data: &{name:Alex age:26}
Person data: &{name:Alex age:27}
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x0 addr=0x0 pc=0x1e5645]

goroutine 1 [running]:
main.(*Person).incAge(0x0)
    e:/code/golang/hello/main.go:19 +0x5
main.main()
    e:/code/golang/hello/main.go:31 +0x165 */

Пользовательская паника

Разработчик может самостоятельно генерировать панику. Для этого нужно использовать встроенную функцию panic(v any), принимающую на свой вход значения любого типа данных. Переданное на вход этой функции значение будет представлять собой информационное сообщение, поэтому лучше использовать строку с подробным пояснением причины самопроизвольного завершения работы приложения:

// baseURL/part_4/4.2/1.go
package main

import "fmt"

type Person struct {
    name string
    age uint8
}

func (p *Person) incAge() {
    p.age+ +
    if p.age > 30 {
        panic(fmt.Sprintf("%s too old!!!", p.name))
    }
}

func main() {

    alex := &Person{
        name: "Alex",
        age: 27,
    }
    for i := 0; i < 6; i+ + {
        alex.incAge()
    }
    fmt.Printf("Person data: %+v\n", alex)
}
/*
panic: Alex too old!!!

goroutine 1 [running]:
main.(*Person).incAge(0xc000004078)
    e:/code/golang/hello/main.go:13 +0x119
main.main()
    e:/code/golang/hello/main.go:24 +0x8e */

Паника и отложенный вызов функции

Даже если панику генерирует разработчик, приложение должно иметь возможность освободить ресурсы: закрыть файл, сокет и т. д. Для этих целей в Go используется отложенный вызов функции с помощью оператора defer. Такие функции выполнятся в любом случае. Единственное условие — вызывать их нужно до того момента в коде, который сгенерирует панику:

// baseURL/part_4/4.3/1.go
package main

import "fmt"

type Person struct {
    name string
    age uint8
}

func (p *Person) incAge() {
    p.age+ +
    if p.age > 30 {
        defer func() {
            fmt.Println("Calling defer function")
        }()
        panic(fmt.Sprintf("%s too old!!!", p.name))
        defer func() {
         // нет смысла вызывать отложенную функцию после паники
            fmt.Println("Useless function")
        }()
    }
}

func main() {

    alex := &Person{
        name: "Alex",
        age: 27,
    }
    for i := 0; i < 6; i+ + {
        alex.incAge()
    }
    fmt.Printf("Person data: %+v\n", alex)
}
/*
Calling defer function
panic: Alex too old!!!

goroutine 1 [running]:
main.(*Person).incAge(0xc000004078)
    e:/code/golang/hello/main.go:16 +0x179
main.main()
    e:/code/golang/hello/main.go:30 +0x8e */

Обработка паники

Благодаря отложенным функциям и встроенной функции recover у разработчиков появляется возможность перехватить панику и обработать ее, после чего принять решение — предотвратить или разрешить экстренное завершение программы:

// baseURL/part_4/4.4/1.go
package main

import (
    "fmt"
    "log"
)

type Person struct {
    name string
    age uint8
}

func (p *Person) incAge() {
    p.age+ +
    if p.age > 30 {
        defer func() {
            if err := recover(); err ! = nil { // обработка паники
                log.Printf("%s has a new life, he (she) is %d years old",
                          p.name, p.age)
            }
        }()
        panic(fmt.Sprintf("%s too old!!!", p.name))
    }
}

func main() {

    alex := &Person{
        name: "Alex",
        age: 27,
    }
    for i := 0; i < 6; i+ + {
        alex.incAge()
    }
    fmt.Printf("Person data: %+v\n", alex)
}
/*
2022/06/15 10:37:00 Alex has a new life, he (she) is 31 years old
2022/06/15 10:37:00 Alex has a new life, he (she) is 32 years old
2022/06/15 10:37:00 Alex has a new life, he (she) is 33 years old
Person data: &{name:Alex age:33} */

Обратите внимание на вывод сообщений в терминале. Для него мы использовали возможность пакета log, позволяющего логировать информацию. Паника была перехвачена и обработана три раза, поэтому приложение завершило свою работу в штатном режиме. Функция recover возвращает значение типа any, которое, по сути, является пустым указателем. Если после вызова функции в переменной err хранится nil, то паника в процессе работы приложения не генерировалась; в противном случае управление перейдет на блок ее обработки в тело if.

Этот способ позволяет обрабатывать панику на разных уровнях приложения. Так, например, тело обработчика само может генерировать панику, которая приведет к завершению приложения или ее обработке на более верхнем уровне приложения:

// baseURL/part_4/4.4/2.go
package main

import (
    "fmt"
    "log"
)

type Person struct {
    name string
    age uint8
}

func (p *Person) incAge() {
    p.age+ +
    if p.age > 30 {
        defer func() {
            if err := recover(); err ! = nil {
                log.Printf("%s has a new life, he (she) is %d years old",
                          p.name, p.age)
                if p.age > 35 {
                    // снова генерируем панику
                    panic(fmt.Sprintf("%s super old!!!", p.name))
                }
            }
        }()
        panic(fmt.Sprintf("%s too old!!!", p.name))
    }
}

func main() {
  
    alex := &Person{
        name: "Alex",
        age: 27,
    }
    defer func() {
        if err := recover(); err ! = nil {
            log.Printf("%s, welcome to club: 'А вот в наши времена'", alex.name)
        }
    }()
    for i := 0; i < 20; i+ + {
        alex.incAge()
    }
    fmt.Printf("Person data: %+v\n", alex)
}
/*
2022/06/15 10:49:36 Alex has a new life, he (she) is 31 years old
2022/06/15 10:49:36 Alex has a new life, he (she) is 32 years old
2022/06/15 10:49:36 Alex has a new life, he (she) is 33 years old
2022/06/15 10:49:36 Alex has a new life, he (she) is 34 years old
2022/06/15 10:49:36 Alex has a new life, he (she) is 35 years old
2022/06/15 10:49:36 Alex has a new life, he (she) is 36 years old
2022/06/15 10:49:36 Alex, welcome to club: 'А вот в наши времена'
*/

Обработка ошибок в Go

Как встроенные функции Go, так и различные функции сторонних библиотек могут вместе с итоговым значением возвращать значение типа error, благодаря которому разработчик может обработать ошибку, если она возникла при работе вызываемой функции:

// baseURL/part_4/4.5/1.go
package main

import (
    "fmt"
    "strconv"
)

func main() {
    var str string = "10"
    // преобразование строки в целочисленное значение
    value, err := strconv.Atoi(str)
    if err ! = nil { // если err ! = nil, то обрабатываем ошибку
        value = 22
    }
    fmt.Println(value) // 10

    str = "2we"
    value, err = strconv.Atoi(str)
    if err ! = nil { // если err ! = nil, то обрабатываем ошибку
        value = 22
    }
    fmt.Println(value) // 22
}

Создавать ошибки можно с помощью двух функций: New из пакета errors или Errorf из пакета fmt. Второй вариант обычно используется для создания новой ошибки и одновременного оборачивания в нее ранее сгенерированной (подробнее об оборачивании поговорим в разделе 4.9):

// baseURL/part_4/4.5/2.go
package main

import (
    "errors"
    "fmt"
)

func main() {
    myError1 := errors.New("first error")
    myError2 := fmt.Errorf("second error")
    fmt.Println(myError1, myError2) // first error second error
}

Как видно из этого кода, при создании экземпляра ошибки необходимо задать ее описание. Это признак хорошего стиля программирования. Для примера реализуем функцию деления целочисленных значений, которая будет вместе со значением возвращать ошибку при попытке деления на ноль:

// baseURL/part_4/4.5/3.go
package main

import "fmt"

func div(a, b int) (int, error) {
    if b = = 0 {
        return 0, fmt.Errorf("Сan't divide '%v' by zero", a)
    }
    return a / b, nil
}

func printResult(result int, err error) {
    if err ! = nil {
        fmt.Println(err)
    } else {
        fmt.Println(result)
    }
}

func main() {
    a, b := 10, 2
    printResult(div(a, b)) // 5
    a, b = 4, 0
    printResult(div(a, b)) // Сan't divide '4' by zero
}

Облегченные пользовательские ошибки

К пользовательским относятся ошибки, которые вводит сам разработчик при написании приложения. Они создаются на верхнем уровне пакета в целях доступности в других частях приложения и должны начинаться с префикса Err:

// baseURL/part_4/4.6/1.go
package main

import (
    "errors"
    "fmt"
)

// Объявление пользовательского варианта ошибки деления на ноль
var ErrDivByZero = errors.New("divide by zero")

func div(a, b int) (int, error) {
    if b = = 0 {
      return 0, ErrDivByZero
    }
    return a / b, nil
}

func printResult(result int, err error) {
    if err ! = nil {
        fmt.Println(err)
    } else {
        fmt.Println(result)
    }
}

func main() {
    a, b := 10, 2
    printResult(div(a, b)) // 5
    a, b = 4, 0
    printResult(div(a, b)) // divide by zero
}

Определение типа ошибки

В некоторых ситуациях из функции могут возвращаться различные варианты ошибок, и их нужно обрабатывать способами, которые отличаются друг от друга. Для этого принято использовать условную конструкцию swsitch и функцию Is из пакета errors.

Для примера добавим в код из предыдущего раздела еще один вариант пользовательской ошибки:

// baseURL/part_4/4.7/1.go
package main

import (
    "errors"
    "fmt"
)

var ErrDivByZero = errors.New("divide by zero")
var ErrDivByOne = errors.New("divide by one")

func div(a, b int) (int, error) {
    if b = = 0 {
        return 0, ErrDivByZero
    }
    if b = = 1 {
        return a, ErrDivByOne
    }
    return a / b, nil
}

func printResult(result int, err error) {
    if err ! = nil {
        switch {
        case errors.Is(err, ErrDivByZero):
            fmt.Println(err)
        case errors.Is(err, ErrDivByOne):
            fmt.Printf("result = %d, but was %v\n", result, err)
        default:
            fmt.Printf("unexpected error: %v\n", err)
        }

    } else {
        fmt.Println(result)
    }
}

func main() {
    printResult(div(10, 2)) // 5
    printResult(div(4, 0)) // divide by zero
    printResult(div(12, 1)) // result = 12, but was divide by one
}

Расширенные пользовательские ошибки

Сейчас нам возвращается только сообщение о том, что ошибка произошла, и в зависимости от ее типа можно выстроить цепочку обработки. Такого варианта должно хватить в большинстве случаев, но иногда необходимо, чтобы ошибка предоставляла больше возможностей. Например, имела поля с данными, которые можно использовать в процессе ее обработки.

Для начала напомню, что тип error — это интерфейс, задающий всего один метод Error() string, а значит, нам ничто не мешает определить структуру, которая будет хранить дополнительную информацию об ошибке, и добавить ей аналогичный метод (вспоминаем о динамическом полиморфизме). А привести тип error к пользовательскому типу структуры, переопределяющей метод Error() string, позволяет функция As из пакета errors:

// baseURL/part_4/4.8/1.go
package main

import (
    "errors"
    "fmt"
)

type DivError struct {
    Numerator   int
    Denominator int
    Msg         string
}

func (d *DivError) Error() string {
    return d.Msg
}

func div(a, b int) (int, error) {
    if b = = 0 {
        return 0, &DivError{
            Numerator:   a,
            Denominator: b,
            Msg:         fmt.Sprintf("Сan't divide '%v' by zero", a),
        }
    }
    if b = = 1 {
        return a, &DivError{
            Numerator:   a,
            Denominator: b,
            Msg:         "Divide by one",
        }
    }
    return a / b, nil
}

func printResult(result int, err error) {
    if err ! = nil {
        var myDivError *DivError
        switch {
        case errors.As(err, &myDivError):
            fmt.Printf("Numerator: %d, Denominator: %d, Error: %s\n",
                        myDivError.Numerator, myDivError.Denominator,
                        myDivError.Msg)
            if myDivError.Denominator = = 1 {
                fmt.Printf("result = %d, but was %v\n",
                    myDivError.Numerator, myDivError.Msg)
            }
        default:
            fmt.Printf("unexpected error: %v\n", err)
        }

    } else {
        fmt.Println(result)
    }
}

func main() {
    printResult(div(10, 2)) // 5
    printResult(div(4, 0))
    //Numerator: 4, Denominator: 0, Error: Сan't divide '4' by zero
    printResult(div(12, 1))
    // Numerator: 12, Denominator: 1, Error: Divide by one
    // result = 12, but was divide by one
}

Вложенные ошибки

Обрабатывать ошибки сразу по мере их возникновения не всегда разумно. Это связано с тем, что на текущем уровне мы можем не иметь всей информации, позволяющей корректно обработать ошибку и восстановить правильную работу приложения. Поэтому ошибка может передаваться до того момента, пока не будет достигнут оптимальный уровень ее обработки. Но одно дело — каждый раз генерировать новую ошибку и передавать ее на уровень выше, и другое — выстроить цепочку из таких ошибок, чтобы в момент ее обработки не только выполнить логирование, содержащее трассировку вложенных ошибок, но и иметь доступ к первопричине ее возникновения.

Такие цепочки можно выстроить с помощью функции Errorf из пакета fmt и в момент указания описания ошибки в форматированной строке использовать параметр %w для передачи ошибки более низкого уровня. Этот механизм называется обертыванием (упаковкой) ошибок. А чтобы изъять из описания ошибку более низкого уровня (распаковать, развернуть), необходимо использовать функцию Unwrap из пакета errors.

Вот пример того, как создать собственную цепочку вложенных ошибок:

// baseURL/part_4/4.9/1.go
package main

import (
    "errors"
    "fmt"
)

type User struct {
    ID uint
    Name string
    Age uint8
}

type UserDB struct {
    users []User
}

func (db *UserDB) FindUserWithID(id uint) (*User, error) {
    for _, it := range db.users {
        if it.ID = = id {
            return &it, nil
        }
    }
    return nil, fmt.Errorf("User with id=%v not found", id)
}

func (db *UserDB) SetUserAge(id uint, age uint8) error {
    for _, it := range db.users {
        if it.ID = = id {
            it.Age = age
            return nil
        }
    }
    return fmt.Errorf("User with id=%v not found", id)
}

func (db *UserDB) AddUser(user User) error {
    for _, it := range db.users {
        if it.ID = = user.ID {
            return fmt.Errorf("User with id=%v already exist", user.ID)
        }
    }
    db.users = append(db.users, user)
    return nil
}

func (db *UserDB) DeleteUserWithName(name string) error {
    index := -1
    for idx, it := range db.users {
        if it.Name = = name {
            index = idx
            break
        }
    }
    if index < 0 {
        return fmt.Errorf("User %s not found", name)
    }
    db.users = append(db.users[:index], db.users[index+1:].. .)
    return nil
}

// функции для работы с БД и организации цепочек ошибок
func findUserWithID(db *UserDB, id uint) (*User, error) {
    user, err := db.FindUserWithID(id)
    if err ! = nil {
        // вернется ошибка из двух вложений
        return nil, fmt.Errorf("DataBase error: %w", err)
    }
    return user, nil
}

func setUserAge(db *UserDB, id uint, age uint8) error {
    err := db.SetUserAge(id, age)
    if err ! = nil {
        // вернется ошибка из двух вложений
        return fmt.Errorf("DataBase user data error: %w", err)
    }
    return nil
}

func addUser(db *UserDB, user User) error {
    err := db.AddUser(user)
    if err ! = nil {
        // вернется ошибка из двух вложений
        return fmt.Errorf("Add user error: %w", err)
    }
    return nil
}

func main() {
    dbUsers := UserDB{
        []User{
            {2, "Alex", 28},
            {1, "Max", 23},
            {10, "German", 35},
            {6, "Oleg", 19},
        },
    }

    user, err := findUserWithID(&dbUsers, 2)
    if err ! = nil {
        fmt.Println(err)
        // распаковка трассировки ошибок
        fmt.Println(errors.Unwrap(err))
    }
    fmt.Printf("%+v\n", user) // &{ID:2 Name:Alex Age:28}

    user, err = findUserWithID(&dbUsers, 12)
    if err ! = nil {
        fmt.Println(err) // DataBase error: User with id=12 not found
        // распаковка трассировки ошибок
        fmt.Println(errors.Unwrap(err)) // User with id=12 not found
    }
    fmt.Printf("%+v\n", user) // <nil>

    newErr := addUser(&dbUsers, User{6, "Li", 45})
    if newErr ! = nil {
        fmt.Println(newErr)
        // Add user error: User with id=6 already exist
        // распаковка трассировки ошибок
        fmt.Println(errors.Unwrap(newErr))
        // User with id=6 already exist
    }
}

Резюме

В этой главе мы рассмотрели механизм обработки ошибок и паник. Тип error относится к такому типу ошибок, которые можно обработать, после чего программа продолжит функционирование. В свою очередь, паника (panic) может привести к самопроизвольному завершению работы приложения. Обычно ее невозможно предвидеть, но Go позволяет восстановить работу приложения, не завершая ее экстренно. Чаще всего к панике приводит обращение по несуществующему индексу среза, попытка работы с указателем, хотя там хранится nil, неправильное использование мьютексов, попытка работы с закрытым файлом и многое другое.

Кроме того, в Go можно задавать пользовательские структуры, которые будут использоваться при обработке ошибок и давать больше информации, чем классический способ генерации и обработки ошибок на основе текстовых сообщений.

"Огромное спасибо моей семье и всем, кто морально поддерживал на этом нелегком пути. Очень надеюсь, что книга поможет сделать первый шаг в изучении и использовании Go. Вас ждут не только интересные вызовы и масштабные проекты, но и ситуации, которые потребуют от вас немалой выдержки и упорства. Кто знает, возможно, именно из-за подобных моментов я в свое время и влюбился в IT. А сейчас — ваша очередь".

Станислав Чернышев, https://habr.com/ru/users/MADTeacher/


Оформить предзаказ на книгу: «Основы Go» можно на нашем сайте.

Для Хаброжителей скидка 35% по промокоду - Предзаказ