🐹 Go

Projeto prático final em Go (Golang): um guia detalhado

Что это такое и зачем? (аналогия из реальной жизни)

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

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

Итоговый проект — это твой диплом повара. Пока ты не приготовишь этот обед, ты не сможешь назвать себя настоящим программистом.

Зачем это нужно? Во-первых, это проверка себя. Ты увидишь, что действительно знаешь, а что нужно подучить. Во-вторых, это твоё портфолио. Когда ты будешь искать работу или показывать друзьям, что ты умеешь, ты скажешь: «Вот, я написал полноценную программу на Go». В-третьих, это невероятно интересно. Создавать что-то своё, большое и полезное — это совсем другое чувство, чем решать скучные задачки из учебника.

Теория: как это работает изнутри

Любой большой проект, даже если это простая игра или калькулятор, состоит из трёх главных частей. Давай разберём их на примере программы «Учёт расходов».

1. Ввод и вывод (UI — User Interface)

Это то, как пользователь общается с программой. В консольных программах на Go это обычно fmt.Scan() для ввода и fmt.Println() для вывода. Пользователь вводит: «Купил пиццу за 500 рублей». Программа выводит: «Добавлено. Всего потрачено: 5000 рублей».

2. Обработка данных (Business Logic)

Это мозг программы. Здесь происходят все вычисления, проверки и изменения. Например: «Если сумма расхода больше 1000, то вывести предупреждение "Многовато!"». Или: «Посчитать средний расход за неделю». Вся логика, ради которой программа создаётся, живёт здесь.

3. Хранение данных (Data Storage)

Программа должна что-то запоминать. Пока она работает, данные хранятся в переменных, массивах (слайсах) и структурах. Но когда программа выключится, всё пропадёт. Чтобы данные сохранились навсегда, их нужно записывать в файл или базу данных. В нашем проекте мы будем использовать простой текстовый файл.

Запомни золотое правило: разделяй эти три части. Не пиши логику внутри кода, который рисует меню. И не сохраняй файлы внутри кода, который считает расходы. Так твою программу будет легко чинить и улучшать.

Синтаксис в Go (Golang): все формы и варианты

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

Пакеты и импорт

Любая программа на Go начинается с пакета. Для проекта мы будем использовать package main. Импортируем нужные библиотеки:

package main

import (
    "fmt"       // Для вывода на экран
    "os"        // Для работы с файлами и завершения программы
    "bufio"     // Для удобного чтения с клавиатуры
    "strconv"   // Для превращения строк в числа
    "strings"   // Для работы с текстом (например, обрезать пробелы)
    "time"      // Для работы с датами и временем
)

Структуры (struct) — твои собственные типы данных

В большом проекте нельзя хранить всё в отдельных переменных. Нужно группировать данные. Структура — это как анкета с полями.

// Структура для одного расхода
type Expense struct {
    Amount   float64   // Сумма
    Category string    // Категория (Еда, Транспорт)
    Date     time.Time // Дата
    Comment  string    // Комментарий
}

Слайсы (динамические массивы)

Мы не знаем заранее, сколько расходов введёт пользователь. Слайс может расти.

var expenses []Expense // Пустой слайс для хранения всех расходов

Методы — функции, привязанные к структуре

Это удобно: мы говорим структуре «Посчитай сам себя».

// Метод для структуры Expense, который выводит информацию о расходе
func (e Expense) Display() {
    fmt.Printf("Дата: %s | Категория: %s | Сумма: %.2f | Комментарий: %s
",
        e.Date.Format("02.01.2006"), e.Category, e.Amount, e.Comment)
}

Работа с файлами

Чтобы данные не пропадали, мы будем их сохранять. Используем пакет os и bufio.

// Сохранение данных в файл
func SaveToFile(filename string, expenses []Expense) error {
    file, err := os.Create(filename) // Создаём файл
    if err != nil {
        return err // Если ошибка — возвращаем её
    }
    defer file.Close() // Обязательно закроем файл в конце

    for _, exp := range expenses {
        // Записываем каждую строку в формате: Сумма|Категория|Дата|Комментарий
        line := fmt.Sprintf("%.2f|%s|%s|%s
", exp.Amount, exp.Category, exp.Date.Format(time.RFC3339), exp.Comment)
        _, err := file.WriteString(line)
        if err != nil {
            return err
        }
    }
    return nil // Всё хорошо
}

Примеры кода — от простого к сложному

Мы будем строить наш проект постепенно. Каждый пример — это шаг вперёд. В конце у нас получится полноценная программа «Учёт личных расходов».

Пример 1: Приветствие и простое меню

Начнём с самого простого: покажем пользователю меню и спросим, что он хочет сделать.

package main

import "fmt"

func main() {
    // Бесконечный цикл для работы программы
    for {
        fmt.Println("
=== Учёт расходов ===")
        fmt.Println("1. Добавить расход")
        fmt.Println("2. Показать все расходы")
        fmt.Println("3. Выйти")
        fmt.Print("Выберите действие: ")

        var choice int
        fmt.Scan(&choice) // Считываем выбор пользователя

        switch choice {
        case 1:
            fmt.Println("Вы выбрали: Добавить расход")
        case 2:
            fmt.Println("Вы выбрали: Показать расходы")
        case 3:
            fmt.Println("До свидания!")
            return // Выход из программы
        default:
            fmt.Println("Неверный ввод. Попробуйте снова.")
        }
    }
}

Объяснение: Мы создали бесконечный цикл for, который не даёт программе закрыться. Внутри мы рисуем меню и ждём ввод. switch проверяет, что выбрал пользователь. Если выбрал 3 — программа завершается.

Пример 2: Добавление расхода с проверкой ввода

Теперь научимся добавлять расход. Важно: пользователь может ошибиться и ввести буквы вместо цифр. Нужно это проверить.

package main

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

func main() {
    reader := bufio.NewReader(os.Stdin) // Создаём читателя с клавиатуры

    fmt.Print("Введите сумму расхода: ")
    amountStr, _ := reader.ReadString('
')          // Читаем до нажатия Enter
    amountStr = strings.TrimSpace(amountStr)          // Убираем лишние пробелы и переносы строк

    // Пробуем превратить строку в число с плавающей точкой
    amount, err := strconv.ParseFloat(amountStr, 64)
    if err != nil {
        fmt.Println("Ошибка: нужно ввести число, а не текст!")
        return
    }

    fmt.Printf("Вы ввели сумму: %.2f
", amount)
}

Объяснение: bufio.NewReader позволяет читать целую строку, а не одно слово. strings.TrimSpace убирает лишние символы. strconv.ParseFloat пытается превратить строку в число. Если не получается — программа не падает, а сообщает об ошибке.

Пример 3: Создание структуры и слайса расходов

Объединим данные в структуру и создадим список для хранения.

package main

import (
    "fmt"
    "time"
)

// Определяем структуру Expense
type Expense struct {
    Amount   float64
    Category string
    Date     time.Time
    Comment  string
}

func main() {
    // Создаём слайс расходов
    expenses := []Expense{}

    // Добавляем первый расход вручную для примера
    firstExpense := Expense{
        Amount:   150.50,
        Category: "Еда",
        Date:     time.Now(),
        Comment:  "Обед в столовой",
    }

    // Добавляем в слайс
    expenses = append(expenses, firstExpense)

    // Выводим информацию
    fmt.Println("Список расходов:")
    for i, exp := range expenses {
        fmt.Printf("%d. Сумма: %.2f | Категория: %s | Дата: %s | Комментарий: %s
",
            i+1, exp.Amount, exp.Category, exp.Date.Format("02.01.2006"), exp.Comment)
    }
}

Объяснение: Мы создали тип Expense с четырьмя полями. Слайс expenses хранит все расходы. Функция append добавляет новый элемент в конец слайса. Цикл for range проходит по всем элементам и выводит их.

Пример 4: Метод для отображения расхода

Сделаем код чище: пусть структура сама умеет себя показывать.

package main

import (
    "fmt"
    "time"
)

type Expense struct {
    Amount   float64
    Category string
    Date     time.Time
    Comment  string
}

// Метод Display для структуры Expense
func (e Expense) Display(index int) {
    fmt.Printf("%d. [%s] %s: %.2f руб. (%s)
",
        index, e.Date.Format("02.01.2006"), e.Category, e.Amount, e.Comment)
}

func main() {
    expenses := []Expense{
        {Amount: 500, Category: "Транспорт", Date: time.Now(), Comment: "Такси"},
        {Amount: 1200, Category: "Развлечения", Date: time.Now(), Comment: "Кино"},
    }

    fmt.Println("Мои расходы:")
    for i, exp := range expenses {
        exp.Display(i + 1) // Вызываем метод у каждого элемента
    }
}

Объяснение: Метод Display привязан к структуре. Теперь, чтобы вывести расход, мы просто пишем exp.Display(). Код стал понятнее и короче.

Пример 5: Сохранение данных в файл

Теперь самое важное — научимся сохранять данные, чтобы они не пропадали.

package main

import (
    "fmt"
    "os"
    "time"
)

type Expense struct {
    Amount   float64
    Category string
    Date     time.Time
    Comment  string
}

// Функция для сохранения в файл
func SaveExpenses(filename string, expenses []Expense) error {
    // Создаём файл (если существует — перезаписываем)
    file, err := os.Create(filename)
    if err != nil {
        return fmt.Errorf("не удалось создать файл: %w", err)
    }
    // Отложенное закрытие файла (выполнится при выходе из функции)
    defer file.Close()

    // Записываем каждый расход в отдельную строку
    for _, exp := range expenses {
        // Формат: Сумма|Категория|Дата|Комментарий
        line := fmt.Sprintf("%.2f|%s|%s|%s
",
            exp.Amount, exp.Category, exp.Date.Format(time.RFC3339), exp.Comment)
        _, err := file.WriteString(line)
        if err != nil {
            return fmt.Errorf("ошибка записи: %w", err)
        }
    }
    return nil // Всё хорошо
}

func main() {
    expenses := []Expense{
        {Amount: 250, Category: "Еда", Date: time.Now(), Comment: "Завтрак"},
        {Amount: 800, Category: "Одежда", Date: time.Now(), Comment: "Носки"},
    }

    err := SaveExpenses("my_expenses.txt", expenses)
    if err != nil {
        fmt.Println("Ошибка сохранения:", err)
    } else {
        fmt.Println("Данные успешно сохранены в файл my_expenses.txt")
    }
}

Объяснение: os.Create создаёт файл. defer file.Close() гарантирует, что файл закроется, даже если произойдёт ошибка. Мы записываем данные в формате CSV-like (разделитель — вертикальная черта). fmt.Errorf с %w позволяет «обернуть» ошибку, добавив свой комментарий.

Пример 6: Загрузка данных из файла

Если мы научились сохранять, нужно научиться и загружать.

package main

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

type Expense struct {
    Amount   float64
    Category string
    Date     time.Time
    Comment  string
}

// Функция загрузки из файла
func LoadExpenses(filename string) ([]Expense, error) {
    // Открываем файл для чтения
    file, err := os.Open(filename)
    if err != nil {
        // Если файла нет — это не ошибка, просто возвращаем пустой список
        if os.IsNotExist(err) {
            return []Expense{}, nil
        }
        return nil, fmt.Errorf("не удалось открыть файл: %w", err)
    }
    defer file.Close()

    var expenses []Expense
    scanner := bufio.NewScanner(file) // Создаём сканер для чтения строк

    for scanner.Scan() {
        line := scanner.Text() // Читаем одну строку
        parts := strings.Split(line, "|") // Разделяем по символу |

        // Проверяем, что у нас ровно 4 части
        if len(parts) != 4 {
            continue // Пропускаем битые строки
        }

        // Преобразуем строки обратно в числа и даты
        amount, _ := strconv.ParseFloat(parts[0], 64)
        category := parts[1]
        date, _ := time.Parse(time.RFC3339, parts[2])
        comment := parts[3]

        // Создаём структуру и добавляем в слайс
        expense := Expense{
            Amount:   amount,
            Category: category,
            Date:     date,
            Comment:  comment,
        }
        expenses = append(expenses, expense)
    }

    // Проверяем, не было ли ошибок при сканировании
    if err := scanner.Err(); err != nil {
        return nil, fmt.Errorf("ошибка чтения файла: %w", err)
    }

    return expenses, nil
}

func main() {
    expenses, err := LoadExpenses("my_expenses.txt")
    if err != nil {
        fmt.Println("Ошибка загрузки:", err)
        return
    }

    fmt.Printf("Загружено %d расходов.
", len(expenses))
    for _, exp := range expenses {
        fmt.Printf(" - %s: %.2f руб.
", exp.Category, exp.Amount)
    }
}

Объяснение: bufio.NewScanner читает файл построчно. strings.Split разбивает строку на части. time.Parse превращает строку обратно в дату. Мы проверяем, что в строке ровно 4 части, иначе пропускаем её (это защита от битых данных).

Пример 7: Полный цикл программы с меню

Соберём всё вместе. Это уже почти полноценный проект.

package main

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

type Expense struct {
    Amount   float64
    Category string
    Date     time.Time
    Comment  string
}

const dataFile = "expenses.txt"

func main() {
    // Загружаем данные при старте
    expenses, err := LoadExpenses(dataFile)
    if err != nil {
        fmt.Println("Ошибка загрузки данных, начинаем с чистого листа:", err)
        expenses = []Expense{}
    }

    reader := bufio.NewReader(os.Stdin)

    for {
        fmt.Println("
=== Учёт расходов ===")
        fmt.Println("1. Добавить расход")
        fmt.Println("2. Показать все расходы")
        fmt.Println("3. Показать итог")
        fmt.Println("4. Выйти и сохранить")
        fmt.Print("Выберите действие: ")

        choiceStr, _ := reader.ReadString('
')
        choiceStr = strings.TrimSpace(choiceStr)
        choice, _ := strconv.Atoi(choiceStr)

        switch choice {
        case 1:
            // Добавление расхода
            fmt.Print("Введите сумму: ")
            amountStr, _ := reader.ReadString('
')
            amountStr = strings.TrimSpace(amountStr)
            amount, err := strconv.ParseFloat(amountStr, 64)
            if err != nil || amount <= 0 {
                fmt.Println("Ошибка: введите положительное число.")
                continue
            }

            fmt.Print("Введите категорию (Еда, Транспорт и т.д.): ")
            category, _ := reader.ReadString('
')
            category = strings.TrimSpace(category)

            fmt.Print("Введите комментарий: ")
            comment, _ := reader.ReadString('
')
            comment = strings.TrimSpace(comment)

            expense := Expense{
                Amount:   amount,
                Category: category,
                Date:     time.Now(),
                Comment:  comment,
            }
            expenses = append(expenses, expense)
            fmt.Println("Расход добавлен!")

        case 2:
            // Показ всех расходов
            if len(expenses) == 0 {
                fmt.Println("Расходов пока нет.")
            } else {
                fmt.Println("
Список расходов:")
                for i, exp := range expenses {
                    fmt.Printf("%d. [%s] %s: %.2f руб. — %s
",
                        i+1, exp.Date.Format("02.01.2006"), exp.Category, exp.Amount, exp.Comment)
                }
            }

        case 3:
            // Подсчёт итога
            var total float64
            for _, exp := range expenses {
                total += exp.Amount
            }
            fmt.Printf("Всего потрачено: %.2f руб.
", total)

        case 4:
            // Сохранение и выход
            err := SaveExpenses(dataFile, expenses)
            if err != nil {
                fmt.Println("Ошибка при сохранении:", err)
            } else {
                fmt.Println("Данные сохранены. До свидания!")
            }
            return

        default:
            fmt.Println("Неверный ввод. Попробуйте снова.")
        }
    }
}

// Функции SaveExpenses и LoadExpenses — такие же, как в примерах 5 и 6
func SaveExpenses(filename string, expenses []Expense) error {
    file, err := os.Create(filename)
    if err != nil {
        return fmt.Errorf("не удалось создать файл: %w", err)
    }
    defer file.Close()

    for _, exp := range expenses {
        line := fmt.Sprintf("%.2f|%s|%s|%s
",
            exp.Amount, exp.Category, exp.Date.Format(time.RFC3339), exp.Comment)
        _, err := file.WriteString(line)
        if err != nil {
            return fmt.Errorf("ошибка записи: %w", err)
        }
    }
    return nil
}

func LoadExpenses(filename string) ([]Expense, error) {
    file, err := os.Open(filename)
    if err != nil {
        if os.IsNotExist(err) {
            return []Expense{}, nil
        }
        return nil, fmt.Errorf("не удалось открыть файл: %w", err)
    }
    defer file.Close()

    var expenses []Expense
    scanner := bufio.NewScanner(file)

    for scanner.Scan() {
        line := scanner.Text()
        parts := strings.Split(line, "|")
        if len(parts) != 4 {
            continue
        }

        amount, _ := strconv.ParseFloat(parts[0], 64)
        category := parts[1]
        date, _ := time.Parse(time.RFC3339, parts[2])
        comment := parts[3]

        expenses = append(expenses, Expense{
            Amount:   amount,
            Category: category,
            Date:     date,
            Comment:  comment,
        })
    }

    if err := scanner.Err(); err != nil {
        return nil, fmt.Errorf("ошибка чтения файла: %w", err)
    }

    return expenses, nil
}

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

Сравнение с Python: чем похоже и чем отличается

Характеристика Go (Golang) Python
Типизация Строгая статическая. Тип переменной указывается явно и не меняется. Динамическая. Тип определяется сам, может меняться.
Скорость Очень быстрый. Компилируется в машинный код. Медленнее. Интерпретируется.
Обработка ошибок Явная. Функции возвращают ошибку, которую нужно проверить. Исключения (try/except). Можно «поймать» ошибку.
Циклы Только for. Нет while. Есть for и while.
Структуры данных Структуры (struct), слайсы, мапы (map). Словари, списки, кортежи, классы.
Параллельность Горутины и каналы. Встроено в язык. Библиотеки threading, asyncio. Сложнее.
Простота для новичка Средняя. Нужно писать больше кода, но он понятный. Высокая. Код очень короткий и читаемый.
Если ты знаешь Python, то Go покажется тебе более строгим и требовательным. Но за эту строгость ты получаешь скорость и надёжность. Программа на Go редко «падает» неожиданно, если ты правильно обработал все ошибки.

Типичные ошибки и подводные камни

Даже опытные программисты на Go иногда попадают в эти ловушки. Вот самые частые:

1. Игнорирование ошибок

Самая популярная ошибка новичков — написать _, _ := fmt.Scan(...) и забыть проверить, что вернулось. Если пользователь введёт букву вместо числа, программа может повести себя странно.

Как избежать: Всегда проверяй ошибки. Если тебе не нужно значение, используй _, но хотя бы проверь, что ошибки нет.

2. Забыл закрыть файл

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

Как избежать: Используй defer file.Close() сразу после открытия файла.

3. Путаница с указателями

В Go есть указатели (* и &). Если ты передаёшь структуру в функцию без указателя, создаётся её копия. Изменения в функции не повлияют на оригинал.

Как избежать: Если функция должна изменить структуру, передавай указатель: func (e *Expense) SetAmount(a float64).

4. Бесконечный цикл без выхода

Если в цикле for нет условия выхода или break, программа зависнет.

Как избежать: Всегда предусматривай способ выхода из цикла (например, пункт меню «Выйти» с return).

5. Неправильный формат даты

В Go даты форматируются не через YYYY-MM-DD, а через специальное число 2006-01-02 15:04:05. Это очень легко забыть.

Как избежать: Запомни магическое число: 2006-01-02 15:04:05 — это 1 час 2 минуты 3 секунды 4 года 5 дня 6 месяца. Или просто копируй из документации.

Лучшие практики и советы профессионалов

Чтобы твой проект был не просто работающим, а красивым и удобным для других, следуй этим советам:

1. Разделяй код на файлы

Не пиши всё в одном main.go. Создай отдельные файлы:

  • main.go — только запуск программы
  • expense.go — структура и методы
  • storage.go — работа с файлами
  • ui.go — меню и ввод/вывод

Так код легче читать и чинить.

2. Используй константы для магических чисел

Вместо if choice == 1 лучше написать:

const (
    MenuAdd    = 1
    MenuShow   = 2
    MenuTotal  = 3
    MenuExit   = 4
)

Если захочешь поменять цифры в меню, не придётся искать их по всему коду.

3. Пиши комментарии на русском, но не перебарщивай

Комментируй почему ты сделал так, а не что ты сделал. Код и так показывает, что он делает.

Плохо: // Прибавляем единицу к i

Хорошо: // Смещаем индекс на 1, потому что пользователи считают с 1, а программисты с 0

4. Тестируй граничные случаи

Проверь, что будет, если:

  • Файл с данными пустой
  • Пользователь ввёл отрицательную сумму
  • Пользователь ввёл очень большое число
  • Файл повреждён (например, в нём не 4 части, а 2)

Твоя программа должна обрабатывать всё это без паники.

5. Используй gofmt

В Go есть встроенный инструмент для форматирования кода. Запусти gofmt -w . в папке с проектом, и код станет красивым и единообразным.

Вопросы для самопроверки

  1. Вопрос: Зачем в итоговом проекте нужно разделять код на логику, ввод/вывод и хранение данных?

    Ответ: Чтобы код было легко читать, изменять и тестировать. Если логика смешана с выводом на экран, то при смене интерфейса (например, с консоли на веб) придётся переписывать всё.

  2. Вопрос: Что такое defer и зачем он нужен при работе с файлами?

    Ответ: defer откладывает выполнение функции до выхода из текущей функции. Это гарантирует, что файл закроется, даже если произойдёт ошибка.

  3. Вопрос: Почему в Go нужно явно проверять ошибки, а не использовать try/except как в Python?

    Ответ: Go создавался для написания надёжных программ. Явная провер