---
title: "Первое приложение: консольный менеджер заметок"
description: "Первое приложение go: собираем консольный менеджер заметок — CLI на структурах, методах, картах и срезах. Финальный проект курса go-basics с меню."
course: "Go с нуля: базовый синтаксис и конструкции"
url: "https://golangmaster.ru/courses/go-basics/lessons/25-pervoe-prilozhenie/"
published: "2026-09-19"
updated: "2026-09-28"
minutes: 30
---

# Первое приложение: консольный менеджер заметок

*Курс «Go с нуля: базовый синтаксис и конструкции», урок 25 из 25. ~30 минут. Обновлено 28.09.2026. Источник: https://golangmaster.ru/courses/go-basics/lessons/25-pervoe-prilozhenie/*

Пора собрать всё изученное в первое приложение go — консольный менеджер заметок: маленький CLI-инструмент, который умеет добавлять записи, печатать их таблицей, находить по номеру, помечать готовыми, удалять, сортировать и искать по подстроке. Ни одной новой синтаксической конструкции в этом уроке не появится: [структуры](/courses/go-basics/lessons/23-struktury/) из двадцать третьего урока станут типом Note и Store, [методы](/courses/go-basics/lessons/24-metody/) — операциями Add, List, Find и Delete, [карта](/courses/go-basics/lessons/19-karty/) id→Note — хранилищем, а циклы, срезы и switch из середины курса — меню и обработкой команд. Каждый ранимый пример прогнан на обоих движках песочницы, а места, где песочница бессильна, — ввод с клавиатуры и настоящее время — показаны в отдельных блоках для локального запуска с честными оговорками.

### Первое приложение go: что мы собираем

Двадцать четыре урока мы тренировались на упражнениях: счётчиках, игроках, прямоугольниках. Упражнение живёт один запуск и умирает вместе с Println. Сегодня — финальный проект курса: первое приложение go, которое похоже на настоящую программу. Это консольный менеджер заметок — CLI-инструмент, в котором вы храните короткие записи: «купить хлеб», «позвонить маме», «дописать отчёт». Программа печатает меню, читает команду, выполняет действие и снова печатает меню — пока вы не выберете выход. Такой цикл «спроси — пойми — сделай» лежит в основе тысячи утилит командной строки, от калькуляторов до git.

Список возможностей менеджера заметок небольшой, но полный: добавить заметку (каждой автоматически присваивается номер), напечатать все заметки ровной таблицей, найти заметку по номеру, пометить её готовой, удалить, отсортировать по заголовку и найти все записи по подстроке. Каждая возможность — это один метод или одна маленькая функция, а всё вместе — примерно сотня строк с комментариями. Обратите внимание на слово «собираем»: ни один инструмент в этом уроке не новый. Структуры, поля, композитные литералы — из урока о структурах; приёмники-значение и указатель — из урока о методах; карта, delete и проверка наличия — из урока о картах; срезы и append — из уроков семнадцатого и восемнадцатого; switch, циклы, строки — из середины курса. Финальный проект тем и ценен, что показывает: вы уже знаете весь язык, которого достаточно для настоящего инструмента.

### Структурирование проекта: три слоя одной программы

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

Слой данных — это тип Note: структура с полями ID, Title, Body и временем создания. Тип описывает, что такое заметка в вашей программе, — и только это. Слой операций — тип Store с картой заметок внутри и методами Add, List, Find, Delete: он знает, где лежат данные и как их менять, но не знает, кто и зачем их просит. Слой интерфейса — main с меню: он читает команды пользователя, вызывает методы хранилища и печатает результаты, но сам в данные не лезет. Границы между слоями — это сигнатуры методов: main говорит Add("купить хлеб", "батон"), и ему всё равно, что внутри — карта, срез или файл на диске.

Зачем границы, если программа маленькая? Затем, что настоящий проект растёт: завтра заметкам понадобится приоритет, послезавтра — сохранение в файл. При разделении слоёв каждая такая правка затрагивает один слой и не трогает остальные; при коде-в-одном-main — все сразу. Вы уже касались этой идеи в уроке о методах: сеттер прячет поле за проверкой. Здесь та же мысль в масштабе программы. И ещё один бонус: слои можно проверять по отдельности — методы Store тестируются простым вызовом, без клавиатуры, чем мы и пользуемся в песочнице этого урока.

### Песочница: скелет приложения — Note и Store

Запустите: это работающее ядро менеджера заметок. NewStore() — функция-конструктор: возвращает указатель на Store с пустой картой и счётчиком next = 1. Три вызова s.Add(...) добавляют заметки: внутри Add номер берётся из счётчика, счётчик растёт, готовая Note кладётся в карту по своему номеру. List() собирает значения карты в срез и сортирует по номеру — карта не гарантирует порядка, поэтому сортировка в выводе обязательна. В main заметки печатаются циклом range из урока о range. Опыты. Первый: добавьте четвёртую заметку s.Add("полить цветы", "вечером") — она получит номер 4 автоматически. Второй: поменяйте в sort.Slice сравнение out[i].ID < out[j].ID на > — список напечатается от больших номеров к меньшим. Третий: добавьте поле Done bool в Note и в main напечатайте s.List()[0].Done — ноль false: нулевые значения из урока о переменных работают и здесь, заметка по умолчанию не готова.

```go
package main

import (
        "fmt"
        "sort"
)

// Note — одна заметка менеджера
type Note struct {
        ID        int
        Title     string
        Body      string
        CreatedAt int64
}

// Store — хранилище: карта id→заметка и счётчик номеров
type Store struct {
        notes map[int]Note
        next  int
}

// NewStore готовит пустое хранилище
func NewStore() *Store {
        return &Store{notes: map[int]Note{}, next: 1}
}

// Add добавляет заметку и возвращает её номер
func (s *Store) Add(title, body string) int {
        id := s.next
        s.next++
        s.notes[id] = Note{ID: id, Title: title, Body: body, CreatedAt: 1714500000}
        return id
}

// List возвращает заметки по возрастанию номеров
func (s Store) List() []Note {
        out := []Note{}
        for _, n := range s.notes {
                out = append(out, n)
        }
        sort.Slice(out, func(i, j int) bool {
                return out[i].ID < out[j].ID
        })
        return out
}

func main() {
        s := NewStore()
        s.Add("купить хлеб", "батон и молоко")
        s.Add("позвонить маме", "после работы")
        s.Add("дописать отчёт", "к пятнице")
        for _, n := range s.List() {
                fmt.Printf("%d %s: %s\n", n.ID, n.Title, n.Body)
        }
        fmt.Println("всего:", len(s.List()))
}
```

### Разбор скелета: карта, счётчик и два приёмника

Пройдём по скелету медленно — здесь каждое решение опирается на какой-то из предыдущих уроков. Хранилище — карта map[int]Note: ключ — номер заметки, значение — структура целиком. Почему не срез? Срез из уроков о срезах тоже мог бы хранить заметки, но тогда номер заметки и позиция в срезе слиплись бы: после удаления середины все номера после неё съехали бы, и записка «найди заметку 7» перестала бы означать одно и то же. Карта развязывает номер и порядок: delete по ключу не трогает остальные записи, проверка наличия через запятую-ok отвечает «а есть ли заметка 7?», а len считает записи. Это тот самый выбор структуры данных, о котором шла речь в уроке о картах: ключ здесь — не «какой по счёту», а «какой именно».

Счётчик next решает проблему номеров: каждая заметка получает уникальный растущий номер — 1, 2, 3. Хранился бы номер в len(notes), номера дублировались бы после удалений: две заметки с номером 3. Автонумерация — паттерн из настоящих систем, от баз данных до issue-трекеров, и целиком умещается в две строки id := s.next; s.next++.

Приёмники методов выбраны по правилам урока о методах. Add и Delete меняют состояние — поля notes и next — поэтому у них приёмник-указатель func (s *Store). List и Find только читают — им хватает приёмника-значения func (s Store). Заметьте: List копирует только заголовок Store — карту, которая под капотом ссылка на данные, копировать целиком не нужно; это вы видели в уроке о структурах, где map в структуре ведёт себя как ссылка.

И честная нота о поле CreatedAt. В настоящем приложении время создания заметки хранит тип time.Time из стандартного пакета time — так делает весь мировой Go. Песочница этого не тянет: в резервном движке time.Now() подменяется сырым числом миллисекунд, которое меняется от запуска к запуску, и вывод перестаёт быть воспроизводимым. Поэтому в ранимых примерах урока поле CreatedAt имеет тип int64 и заполнено фиксированным числом — Unix-меткой времени, как её хранят базы данных. Версия с настоящим time.Time — в следующем блоке: она для локального запуска.

### Локальная версия: поле CreatedAt типа time.Time

```go
package main

import (
        "fmt"
        "time"
)

// Note — заметка со временем создания
type Note struct {
        ID        int
        Title     string
        CreatedAt time.Time
}

func main() {
        n := Note{ID: 1, Title: "купить хлеб", CreatedAt: time.Now()}
        fmt.Println(n.Title, n.CreatedAt.Format("02.01 15:04"))
}
```

Кнопки нет: движки песочницы расходятся на этом примере. Настоящий Go и основной движок печатают «купить хлеб» и дату-время создания в формате «день.месяц часы:минуты» — time.Time живёт в пакете time из стандартной библиотеки, time.Now() возвращает текущий момент, а метод Format собирает из него строку по шаблону. Резервный движок подменяет time.Now() сырым числом миллисекунд, поле time.Time в литерале фактически превращается в это число, а метод Format у него не находит — печатает ошибку «у значения типа int нет поля или метода Format» или сырое число. Запустите файл локально командой go run main.go — там время настоящее. Справочно: метка 1714500000 из ранимых примеров — это 30 апреля 2024 года: Unix-время считается в секундах с 1 января 1970.

### Карта id→Note: добавление, поиск, удаление

Слой операций менеджера заметок — это четыре метода над картой, и каждый вы уже писали руками в уроке о картах, только без структур и приёмников. Add кладёт значение по новому ключу: s.notes[id] = Note{...} — присваивание по несуществующему ключу добавляет запись, это вы знаете. Delete удаляет по ключу: delete(s.notes, id) — и карта уменьшается на одну запись, независимо от того, где в «порядке» она жила. Find читает с проверкой: n, ok := s.notes[id] — двухзначное чтение из урока о картах отвечает и «что лежит», и «лежит ли вообще», а заодно обходит ловушку нулевого значения: чтение по отсутствующему ключу возвращает нулевую Note, которую без проверки ok не отличить от настоящей заметки с пустыми полями.

Один нюанс потребовал целого правила из урока о структурах: элементы карты неадресуемы. В настоящем Go написать s.notes[id].Done = true нельзя — компилятор откажет сообщением cannot assign по той же причине, по которой в уроке о структурах не работало m["a"].X = 99. Резервный движок песочницы, кстати, эту строку молча выполняет, и правка видна — удобная ловушка, а не фича: код в расчёте на неё не соберётся на настоящем Go. Правильный ход — трёхшаговый: прочитать заметку во временную переменную, изменить поле у переменной, положить обратно по ключу — он работает одинаково на обоих движках и в настоящем Go. Мы обернули его в метод MarkDone — и он появится в следующем ранимом блоке.

Отдельного разговора заслуживает возвращаемое значение Delete: bool. Операция «удалить заметку 7» имеет два исхода — удалили или такой нет, — и вызывающий код должен уметь их различить. Двухзначный ответ (bool) — та же идиома, что и запятая-ok при чтении: операция возвращает не только результат, но и признак успеха. Помните урок об ошибках и возвратах: пока у нас нет настоящих error-значений, bool — честный минимум для различения исходов, и в менеджере заметок его достаточно.

### Проверьте себя: правка заметки в карте

**Вопрос:** В Store лежит карта notes map[int]Note. Нужно пометить заметку 3 готовой: поле Done становится true. Какая строка соберётся и сработает?

- ✅ n := s.notes[3]; n.Done = true; s.notes[3] = n — прочитать, изменить копию, положить обратно
- s.notes[3].Done = true — прямой доступ к полю элемента карты
- s.notes[3] = true — поле Done можно присвоить напрямую в карту
- delete(s.notes, 3) — пометить готовой значит убрать из карты

Элементы карты неадресуемы в настоящем Go: компилятор отклоняет s.notes[3].Done = true сообщением про cannot assign — та же причина, по которой в уроке о структурах не работала запись m["a"].X = 99. Правильный путь — три шага: прочитать значение во временную переменную (это копия структуры), изменить поле у копии, записать копию обратно по тому же ключу. Вариант с присваиванием true не соберётся — типы не совпадают, а delete не помечает, а удаляет: флаг в отдельной карте done решил бы задачу без правки структуры, и такой вариант мы тоже разберём. Честная оговорка: резервный движок песочницы строку с прямой правкой молча выполняет и правка видна — не проверяйте сборку по нему, трёхшаговый вариант работает одинаково везде.

### Песочница: Delete, Find и флаг done

Запустите: MarkDone возвращает true для существующей заметки 1 и false для несуществующей 7 — сначала проверка наличия, потом запись флага. Find(2) находит «позвонить маме», Find(9) с пустой веткой if отвечает «нет заметки 9». Флаги живут в отдельной карте done map[int]bool: len(s.done) — сколько заметок готово. После Delete(1) готовых ноль: Delete вычищает флаг вместе с заметкой — если бы он этого не делал, карта done засорялась бы номерами уже удалённых записей. Опыты. Первый: добавьте s.Delete(9) до печати — false, удаление несуществующего номера не ошибка. Второй: пометьте готовой заметку 2 и напечатайте len(s.done) после — 2: флаги независимы. Третий: добавьте метод IsDone(id int) bool, возвращающий s.done[id], — чтение по отсутствующему ключу даст false само: нулевое значение bool из урока о переменных работает на вас.

```go
package main

import "fmt"

// Note — заметка менеджера
type Note struct {
        ID    int
        Title string
}

// Store — хранилище с картой флагов готовности
type Store struct {
        notes map[int]Note
        done  map[int]bool
        next  int
}

// NewStore готовит пустое хранилище
func NewStore() *Store {
        return &Store{notes: map[int]Note{}, done: map[int]bool{}, next: 1}
}

// Add добавляет заметку
func (s *Store) Add(title string) int {
        id := s.next
        s.next++
        s.notes[id] = Note{ID: id, Title: title}
        return id
}

// MarkDone ставит флаг готовности
func (s *Store) MarkDone(id int) bool {
        if _, ok := s.notes[id]; !ok {
                return false
        }
        s.done[id] = true
        return true
}

// Find возвращает заметку и признак успеха
func (s Store) Find(id int) (Note, bool) {
        n, ok := s.notes[id]
        return n, ok
}

// Delete удаляет заметку и её флаг
func (s *Store) Delete(id int) bool {
        if _, ok := s.notes[id]; !ok {
                return false
        }
        delete(s.notes, id)
        delete(s.done, id)
        return true
}

func main() {
        s := NewStore()
        s.Add("купить хлеб")
        s.Add("позвонить маме")
        fmt.Println(s.MarkDone(1), s.MarkDone(7))
        if n, ok := s.Find(2); ok {
                fmt.Println("найдена:", n.Title)
        }
        if _, ok := s.Find(9); !ok {
                fmt.Println("нет заметки 9")
        }
        fmt.Println("готовых:", len(s.done))
        s.Delete(1)
        fmt.Println("готовых после удаления:", len(s.done))
}
```

### Флаг done: вторая карта вместо правки структуры

В предыдущем блоке спрятано дизайнерское решение, которое стоит развернуть: флаг готовности живёт НЕ в структуре Note, а в отдельной карте done map[int]bool рядом. Оба пути законны — пометить поле Note{... Done: true} через временную переменную или завести карту флагов — но карта даёт три выгоды.

Первая — простота правки: писать в карту по ключу можно всегда, а правка поля в элементе карты требует трёхшагового обхода неадресуемости. Вторая — целостность: карта done — это готовый индекс «все готовые заметки», len(s.done) считает готовые без цикла, а range по карте перебирает их номера без условий. Третья — разделение сущностей: заметка описывает содержание («что нужно сделать»), карта done описывает состояние («что уже сделано»); когда в следующем курсе заметки переедут в файл или базу, состояние и содержание будут меняться независимо.

Тип map[int]bool особенно приятен из-за нулевых значений: чтение по любому отсутствующему ключу возвращает false, то есть «не готова» — проверка s.done[n.ID] работает даже для заметки, о которой карта никогда не слышала. Это тот же трюк, что и в опыте к предыдущему блоку, и он безопасен ровно потому, что false — осмысленное значение по умолчанию. С map[int]string такой номер не прошёл бы: пустая строка «нет заголовка» и «заголовок не задан» слились бы в одну.

Пара слов о границе приёма: если бы у заметок были три состояния — открыта, в работе, готова — bool перестал бы хватать, и флаг переехал бы в поле status int внутри Note с константами из урока о переменных. Выбор «карта флагов или поле структуры» — это вопрос о числе состояний, и умение его задавать важнее любого конкретного ответа.

### Меню и ввод: for, switch и fmt.Scan

Остался слой интерфейса: меню. Скелет настоящего консольного меню — бесконечный цикл for без условия из уроков о циклах, внутри которого switch по команде пользователя: 1 — добавить, 2 — список, 3 — удалить, 0 — выход. Команду читает fmt.Scan из урока о вводе: он ждёт, пока пользователь наберёт число и нажмёт Enter, и кладёт его в переменную по адресу. Выход из бесконечного цикла — return внутри case 0: функция main завершается, программа заканчивается. Полная версия выглядит так, как в следующем блоке, — и вот здесь песочница бессильна: у неё нет stdin. Интерпретатор живёт на странице браузера, читать клавиатуру не может, а значит, меню с настоящим вводом в песочнице не запустить ни на одном из двух движков. Поэтому блок с меню неранимый — код для локального запуска, — а механику диспетчеризации switch мы проверим ранимым аналогом: список команд задан заранее, цикл их перебирает. Логика разбора команды идентична, просто источник команд другой.

Пара слов о взрослом вводе. fmt.Scan читает значения, разделяемые пробелами, и для одной команды этого достаточно; если когда-нибудь понадобится читать заголовки с пробелами целиком, на помощь придёт bufio.Scanner из стандартной библиотеки — он читает строку целиком. Пакета bufio в песочнице нет, поэтому упоминание — только для локальных экспериментов. Для менеджера заметок fmt.Scan(&cmd) покрывает всё: меню принимает числа.

### Локальная версия: меню с настоящим вводом

```go
package main

import "fmt"

func main() {
        // хранилище и команда
        var cmd int
        for {
                fmt.Print("команда (1-добавить, 2-список, 0-выход): ")
                fmt.Scan(&cmd)
                switch cmd {
                case 1:
                        fmt.Println("добавляю заметку")
                case 2:
                        fmt.Println("печатаю список")
                case 0:
                        fmt.Println("выход")
                        return
                default:
                        fmt.Println("не знаю такую команду")
                }
        }
}
```

Кнопки нет: у песочницы нет stdin ни у основного, ни у резервного движка — интерпретатор живёт в браузере и клавиатуру не видит. Запустите файл локально: go run main.go. Цикл for без условия крутится вечно; каждый виток печатает приглашение, fmt.Scan ждёт число и кладёт его по адресу &cmd — амперсанд из урока об указателях, потому что Scan должен записать значение В вашу переменную. switch разбирает команду: case 0 печатает «выход» и делает return — выход из main, программа завершается; default ловит всё незнакомое. Заметьте: return внутри case завершает всю функцию, а не только switch — именно так меню заканчивает работу. Соедините этот main со Store из ранимых блоков — и получите полный менеджер заметок на своей машине.

### Песочница: диспетчер команд без ввода

Запустите: commands — заранее заданный список команд, который подменяет клавиатуру. Цикл range из урока о range перебирает команды, handle разбирает каждую команду switch-ом: case 1, case 2, case 3 — известные команды, case 0 — выход, default — всё остальное: девятка попадает именно туда. Это тот же диспетчер, что в меню с fmt.Scan, только источник команд другой. Опыты. Первый: добавьте в commands число 4 и case 4 с печатью «ищу заметки» — новая команда меню появляется одной строкой в двух местах. Второй: перенесите switch прямо в main внутрь цикла — поведение то же, но функция handle исчезла: вынос обработчика в функцию делает main короче, это тот же выбор слоёв, что и Store. Третий: уберите return из case 0 и добавьте после цикла fmt.Println("до свидания") — раз цикл range конечен, программа дорабатывает до конца.

```go
package main

import "fmt"

// handle — обработчик одной команды меню
func handle(cmd int) {
        switch cmd {
        case 1:
                fmt.Println("добавляю заметку")
        case 2:
                fmt.Println("печатаю список")
        case 3:
                fmt.Println("удаляю заметку")
        case 0:
                fmt.Println("выход")
        default:
                fmt.Println("не знаю такую команду")
        }
}

func main() {
        commands := []int{1, 2, 9, 3, 0}
        for _, cmd := range commands {
                handle(cmd)
        }
}
```

### Проверьте себя: выход из меню

**Вопрос:** Внутри бесконечного for пользователь вводит 0. В case 0 написано break. Что произойдёт?

- ✅ Цикл продолжится: break завершает только switch, меню закольцуется
- Программа завершится: break выходит из for через switch
- Программа не соберётся: break нельзя писать внутри case
- Цикл продолжится, но switch начнёт пропускать все следующие команды

Классическая ловушка меню на Go. break внутри case относится к switch — он выходит из переключателя, а не из цикла: после break выполнение вернётся в начало бесконечного for, меню напечатает приглашение снова. Чтобы закончить программу, нужен return (выход из main целиком) — или особая форма break с меткой цикла, о которой рассказывал урок о циклах: break menu. У switch в Go и без того не проваливается в следующий case, поэтому break в case часто пишут по привычке из других языков — для единообразия он не мешает, но от цикла не спасает.

### Таблица в консоли: ширины %-3d и %-16s

Печать «в столбик» — fmt.Println на каждую заметку — быстро превращается в кашу: номера разной длины, заголовки разной длины, колонки пляшут. Настоящая консольная таблица строит строки Printf-ом с ширинами из урока о fmt: глагол с числом ширины дополняет значение пробелами до заданной длины, а минус перед числом прижимает значение к левому краю. Запись %-3d означает «целое, ширина 3, слева», %-16s — «строка, ширина 16, слева»: короткие значения добиваются пробелами справа, длинные растут, сколько нужно. Соберите из трёх таких колонок строку с разделителями — и Println внутри цикла range печатает ровную таблицу, где все вертикальные палочки стоят друг под другом.

Ширины подбираются под данные: заголовки заметок длинные, им 16-20 позиций; номерам хватает 3; статус («открыта»/«готова») — 6-8. Хедер таблицы печатается той же строкой формата, только строками вместо чисел: %-3s для надписи ID. Правило одна-строка-формата-для-хедера-и-строк — маленькая, но прочная идиома: меняете структуру таблицы — меняете одну строку, а не десять Println.

Почему это важно в финальном проекте: таблица — первая вещь, которую видит пользователь CLI-инструмента, и «пляшущие» колонки мгновенно выдают учебную программу. При этом под капотом не появилось ничего нового: тот же Printf из урока о выводе, та же строка формата из урока о строках. Проверьте себя предиктом ниже — ширина прижимает и путает многих.

### Что выведет код: ширина колонки

```go
package main

import "fmt"

func main() {
        title := "хлеб"
        fmt.Printf("%-6s|%d|\n", title, 7)
}
```

**Что выведет программа?**

- ✅ хлеб  |7|: ширина 6 дополняет строку двумя пробелами справа из-за минуса
-   хлеб|7|: ширина дополняет строку пробелами слева
- хлеб|7|: ширина работает только с числами, строка печатается как есть
- хлеб   |7|: ширина 6 считается по байтам и добавляет три пробела

%-6s — строка шириной 6, прижатая влево: «хлеб» занимает 4 позиции, добивается двумя пробелами справа, затем разделитель |. Без минуса пробелы встали бы слева: «  хлеб». Ширина работает и с %s, и с %d — вывод одинаковый на обоих движках песочницы: «хлеб  |7|». Русские буквы занимают одну позицию каждая — как и латиница, поэтому расчёт пробелов одинаков для любого языка.

### Сортировка: sort.Slice и запасной путь

Карта хранит заметки эффективно, но перебирает в случайном порядке — вы убедились в этом в уроке о картах, где порядок range каждый запуск разный. Для пользователя менеджера заметок это неприемлемо: список должен быть стабильным. Решение — sort.Slice из урока о функциях, где вы уже встречали функцию-значение: первым аргументом срез, вторым — функция-компаратор func(i, j int) bool, которая отвечает «элемент с индексом i должен стоять раньше j?». Для списка заметок по возрастанию номеров компаратор возвращает out[i].ID < out[j].ID, для сортировки по алфавиту — out[i].Title < out[j].Title: строки в Go сравниваются операцией < напрямую.

В List сортировка стоит после сборки среза: собрали значения карты через append, отсортировали, вернули. После этого любой вызов List печатает один и тот же порядок — детерминизм, которого карта не даёт.

Запасной путь, о котором стоит знать: если сортировать нужно карту по ключам, сначала соберите ключи в срез строк, отсортируйте его sort.Strings и затем переберите карту по отсортированным ключам — range по карте с ключом k в порядке готового среза. Паттерн «собрать ключи → sort.Strings → range по ключам» — универсальный мост между случайной картой и упорядоченным выводом, он встречается в половине реальных программ на Go. sort.Slice мощнее (сортирует что угодно по любому правилу), sort.Strings проще (одна строка, ноль замыканий) — выбирайте по задаче.

### Фильтр по подстроке: strings.Contains

Последняя возможность менеджера заметок — поиск: показать только те записи, в заголовке которых есть подстрока. Механика — strings.Contains из урока о строках: Contains(заголовок, шаблон) возвращает true, если шаблон встречается внутри заголовка. Вставьте её в if внутри range — и цикл печатает только совпавшие записи. Это и весь фильтр: три строки, которые в более взрослой программе станут параметром метода Find(query string) []Note.

Фильтрация по подстроке — самый честный интерфейс поиска для начинающего проекта: пользователь не обязан помнить номер заметки, чтобы её найти, — достаточно помнить слово. По мере роста проекта рядом появятся регистронезависимое сравнение (ToLower от обеих строк перед Contains), поиск по телу заметки, а потом и настоящие индексы — но принцип «перебери и оставь подходящие» останется тем же: в консольных утилитах это и есть поисковый движок в миниатюре.

Соединим арсенал: таблица с ширинами, сортировка через sort.Slice, фильтр через strings.Contains — всё это в следующем ранимом блоке работает на одном срезе заметок с фиксированными данными. Обратите внимание, как сортировка по заголовку и фильтр по подстроке живут в одном цикле, но решают разные задачи: сортировка меняет порядок вывода, фильтр — состав вывода. Разные оси одного вывода — разделение, которое стоит запомнить.

### Песочница: таблица, сортировка и фильтр

Запустите: три заметки заданы литералом среза без имён полей — позиционная форма из урока о структурах. sort.Slice упорядочивает по заголовку: «архив фото» раньше «билеты», «билеты» раньше «звонок» — сравнение строк по алфавиту. Таблица печатается одной строкой формата: заголовок — тем же шаблоном со строками, строки — с числом и строками; статусы выбирает if. Фильтр ниже печатает только записи с «бил» внутри заголовка. Опыты. Первый: поменяйте компаратор на notes[i].ID < notes[j].ID — порядок станет по номерам: 1, 2, 3. Второй: добавьте в таблицу колонку готовости числом %-5v и передайте n.Done — true/false впишется в ширину. Третий: замените шаблон фильтра на «о» — совпадут «звонок в сервис» и «билеты на поезд»: подстрока ищется в любом месте заголовка.

```go
package main

import (
        "fmt"
        "sort"
        "strings"
)

// Note — заметка с флагом готовности
type Note struct {
        ID    int
        Title string
        Done  bool
}

func main() {
        notes := []Note{
                {3, "звонок в сервис", true},
                {1, "архив фото", false},
                {2, "билеты на поезд", false},
        }
        sort.Slice(notes, func(i, j int) bool {
                return notes[i].Title < notes[j].Title
        })
        fmt.Printf("%-3s|%-17s|%s\n", "ID", "ЗАГОЛОВОК", "СТАТУС")
        for _, n := range notes {
                status := "открыта"
                if n.Done {
                        status = "готова"
                }
                fmt.Printf("%-3d|%-17s|%s\n", n.ID, n.Title, status)
        }
        fmt.Println("--- фильтр: бил")
        for _, n := range notes {
                if strings.Contains(n.Title, "бил") {
                        fmt.Println(n.ID, n.Title)
                }
        }
}
```

### Витрина курса: что из 01–24 куда вошло

Менеджер заметок собран — самое время оглянуться и увидеть, что в нём работает из каждого раздела курса. Это не повторение ради повторения: узнавание «вот здесь мой старый инструмент» — главный признак того, что знание прижилось.

Каркас программы — package main, import, func main, комментарии — самый первый урок; переменные, типы и нулевые значения держат все поля структур; строки и руны, числа и логика — материал пятого и шестого уроков — живут в заголовках, номерах и флагах. Управление потоком: if с запятой-ok проверяет наличие заметки, switch диспетчирует команды меню, циклы for и range перебирают список и ключи карты. Коллекции: срезы с append собирают вывод List, а карта id→Note — сердце хранилища; композитные литералы из [урока о структурах](/courses/go-basics/lessons/23-struktury/) заполняют и заметки, и хранилище. Функции из двадцатого урока — NewStore, handle, групповые параметры; возврат пары (Note, bool) в Find — прямое продолжение темы ошибок и возвратов. [Методы](/courses/go-basics/lessons/24-metody/) приклеивают операции к Store, приёмники-указатели правят хранилище, приёмники-значения читают. [Карта](/courses/go-basics/lessons/19-karty/) отвечает за уникальные номера, delete и проверку наличия.

Заметьте, чего в приложении НЕТ: горутин, каналов, интерфейсов, указателей на указатели — всего продвинутого. Это честная граница базового курса: на материале 01–24 уже собираются работающие инструменты, а [оглавление курса](/courses/go-basics/) — место, куда стоит вернуться, если какой-то винтик в машине ощущается шатким.

### Шпаргалка: инструмент → в приложении → урок

| Инструмент | Где работает в менеджере заметок | Урок курса |
| --- | --- | --- |
| type struct + литералы | типы Note и Store, конструктор NewStore | 23 — структуры |
| методы, приёмники value/pointer | Add, Delete, MarkDone меняют хранилище; List, Find читают | 24 — методы |
| map + delete + запятая-ok | хранилище map[int]Note и флаги map[int]bool | 19 — карты |
| срез + append + range | сборка вывода List, перебор таблицы | 15–18 — срезы |
| sort.Slice / sort.Strings | стабильный порядок списка по номеру и по алфавиту | 20 — функции |
| strings.Contains | фильтр заметок по подстроке | 06 — строки |
| fmt.Printf с ширинами | таблица %-3d и %-16s | 09 — вывод |
| for + switch + return | меню: диспетчер команд и выход | 12–14 — циклы |
| fmt.Scan | чтение команды меню (локальный запуск) | 10 — ввод |

### Вставьте пропуски: хранилище заметок

Соберите ядро менеджера: тип Store с картой, метод Add с автонумерацией и метод List со сборкой среза. Собранная программа печатает две заметки по номерам.

```go
package main

import (
        "fmt"
        "sort"
)

// Note — заметка менеджера
type Note struct {
        ID    int
        Title string
}

// Store — хранилище заметок
type Store struct {
        notes map[___]Note
        next  int
}

// Add добавляет заметку и возвращает её номер
func (s *Store) Add(title string) int {
        id := s.___
        s.next++
        s.notes[id] = ___{ID: id, Title: title}
        return id
}

// List возвращает заметки по возрастанию номеров
func (s Store) List() []Note {
        out := []Note{}
        for _, n := range s.notes {
                out = ___(out, n)
        }
        sort.Slice(out, func(i, j int) bool {
                return out[i].ID < out[j].ID
        })
        return out
}

func main() {
        s := Store{notes: map[int]Note{}, next: 1}
        s.Add("купить хлеб")
        s.Add("позвонить маме")
        for _, n := range s.List() {
                fmt.Println(n.ID, n.Title)
        }
}
```

Подсказка: Первый пропуск — тип ключей карты: номер заметки. Второй — поле-счётчик, из которого берётся номер. Третий — имя структуры, которая создаётся литералом. Четвёртый — функция, которая наращивает срез.

Правильные ответы в пропусках: `int`, `next`, `Note`, `append`

### Соберите программу: заметка в карте

Соберите мини-версию приложения: тип Note, тип Store с картой, метод Add с автонумерацией и проверка в main. Собранный вывод — «первая».

Правильный порядок строк:

1. `package main`
2. `import "fmt"`
3. `type Note struct {`
4. `        ID    int`
5. `        Title string`
6. `}`
7. `type Store struct {`
8. `        notes map[int]Note`
9. `        next  int`
10. `}`
11. `func (s *Store) Add(title string) int {`
12. `        id := s.next`
13. `        s.next++`
14. `        s.notes[id] = Note{ID: id, Title: title}`
15. `        return id`
16. `}`
17. `func main() {`
18. `        s := Store{notes: map[int]Note{}, next: 1}`
19. `        s.Add("первая")`
20. `        fmt.Println(s.notes[1].Title)`
21. `}`

> **Честно о песочнице: два движка.** Каждый ранимый пример урока прогнан пробником на обоих движках песочницы, вывод совпадает: карта map[int]Note с литералами структур, присваивание по новому ключу, delete по ключу, чтение с запятой-ok; методы с приёмниками-указателями, меняющие поля хранилища, и приёмники-значения для чтения; sort.Slice по срезу структур с замыканием-компаратором (по int и по строкам); strings.Contains; Printf с ширинами %-3d и %-16s, включая кириллицу; map[int]bool с len и чтением по отсутствующему ключу; for+range+switch с default; поле CreatedAt int64. Недоступно или расходится: (1) stdin — у песочницы его нет на обоих движках, поэтому меню с fmt.Scan и версия с bufio — неранимые блоки для локального запуска, диспетчер проверен на заранее заданном списке команд; (2) время: в резервном движке time.Now() — сырое число миллисекунд, у него нет ни метода Format, ни Unix, поэтому поле CreatedAt в ранимых примерах — int64 с фиксированной меткой, а версия с time.Time — неранимый блок; в настоящем Go и основном движке время работает полноценно; (3) правка поля элемента карты s.notes[3].Done = true — настоящий Go отклоняет сообщением про cannot assign, а резерв молча выполняет, и правка видна: в ранимых примерах правка всегда через временную переменную, что работает одинаково; (4) range по литералу на месте for _, cmd := range []int{1, 9, 0} резерв не парсит — в ранимых список команд сначала кладётся в переменную (в настоящем Go работают оба варианта).

### Карточки: первое приложение

- **Как устроено хранилище менеджера заметок?** — Карта map[int]Note: ключ — уникальный номер заметки, значение — структура целиком. Счётчик next выдаёт номера по нарастающей (id := s.next; s.next++). Карта развязывает номер и порядок: delete не сдвигает остальные записи, запятая-ok проверяет наличие. Добавление — присваивание по новому ключу.
- **Какие приёмники у методов Store и почему?** — Add, Delete, MarkDone — func (s *Store): меняют поля, нужен адрес оригинала. List, Find — func (s Store): только читают, копия заголовка дешевле (карта внутри — ссылка, данные не дублируются). Правило из урока о методах: модификаторы — указатели, вопросы — значения.
- **Почему флаг готовности — отдельная карта map[int]bool?** — Запись в карту всегда разрешена, а правка поля элемента карты требует трёх шагов (элементы неадресуемы). len(s.done) считает готовые без цикла, чтение s.done[id] по отсутствующему ключу само даёт false — нулевое значение bool осмысленно. Delete вычищает флаг вместе с заметкой.
- **Как устроено меню и как из него выйти?** — Бесконечный for без условия, внутри — fmt.Scan(&cmd) и switch по команде: case 1/2/3 — действия, default — незнакомое, case 0 — return: выход из main завершает программу. break внутри case завершает только switch — цикл продолжится; для выхода из цикла нужны return или break с меткой.
- **Как сделать ровную таблицу в консоли?** — Одна строка формата fmt.Printf("%-3d|%-16s|%s\n", ...) на все строки: минус прижимает к левому краю, число — ширина в позициях, короткие значения добиваются пробелами. Хедер печатается тем же шаблоном со строками (%-3s). Ширины подбираются под самые длинные данные колонки.
- **Как отсортировать и отфильтровать заметки?** — Сортировка: sort.Slice(срез, func(i, j int) bool { return ... }) — компаратор отвечает «i раньше j?»; запасной путь для карт — собрать ключи, sort.Strings, range по ключам. Фильтр: if strings.Contains(n.Title, шаблон) внутри range печатает только совпавшие записи.

### Курс завершён: что дальше

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

Домашнее задание — довести приложение до личной версии: добавьте команду «показать готовые», метод Edit для правки заголовка через временную переменную и счётчик команд в меню. Готовую версию запускайте локально: go mod init notes, сохраните код в main.go, go run . — и меню с fmt.Scan оживёт на вашей клавиатуре.

Следующий курс — go-advanced — начинается там, где база закончилась: интерфейсы и полиморфизм, горутины и каналы, контекст и отмена. Всё, что здесь выглядело как правило («приёмники одного типа держите согласованными», «карта не гарантирует порядок»), получит там второе дно и настоящую причину. [Оглавление курса](/courses/go-basics/) останется навсегда — возвращайтесь к нему перед собеседованиями: двадцать пять уроков и одно работающее приложение — это уже не «пробую Go», это «пишу на Go».

## Коротко: что запомнить

- Финальный проект курса — консольный менеджер заметок: CLI с меню, таблицей, поиском, сортировкой и флагами готовности, собранный целиком из инструментов уроков 01–24.
- Структурирование проекта: слой данных (Note), слой операций (Store с методами), слой интерфейса (main с меню) — границы слоёв это сигнатуры методов.
- Хранилище — карта map[int]Note со счётчиком next: уникальные номера, delete без сдвигов, запятая-ok для проверки наличия; порядок карты случайный — вывод сортирует sort.Slice.
- Приёмники по правилам 24-го урока: Add/Delete/MarkDone на указателе меняют хранилище, List/Find на значении читают; Find возвращает пару (Note, bool).
- Флаг готовности — отдельная карта map[int]bool: запись в карту всегда разрешена, len считает готовые, чтение по отсутствующему ключу даёт false.
- Меню — бесконечный for + switch + fmt.Scan; выход — return внутри case 0; break завершает только switch; в песочнице stdin недоступен — интерактивная версия для локального запуска.
- Таблица — Printf с ширинами %-3d и %-16s: минус прижимает влево, хедер печатается тем же шаблоном; сортировка sort.Slice по компаратору, запасной путь — собрать ключи и sort.Strings.
- Фильтр по подстроке — strings.Contains в if внутри range; время создания в настоящем Go хранит time.Time, в песочнице честно заменено на int64-метку.

## Домашнее задание

Довести менеджер заметок до личной версии. Шаг первый: добавьте команду 4 — «показать готовые»: метод Ready() []Note на Store собирает через range по s.done номера готовых и возвращает их заметки по возрастанию номеров (sort.Slice вам уже знаком). Шаг второй: метод Edit(id int, title string) bool — проверяет наличие, читает заметку во временную переменную, меняет заголовок, кладёт обратно; помните: прямой записи s.notes[id].Title = ... не бывает. Шаг третий: соберите полную версию с меню из неранимого блока: for, fmt.Scan(&cmd), switch, case 0 с return, — и запустите локально: go mod init notes, go run main.go. Шаг четвёртый: добавьте в таблицу колонку даты: переведите CreatedAt int64 в человекочитаемый вид хотя бы как число секунд, печатайте %-10d. Шаг пятый: заведите в комментарии три идеи, которые потребуют материалов следующего курса: сохранение в файл, регистронезависимый поиск, свои типы ошибок вместо bool. [Оглавление курса](/courses/go-basics/) — рядом на случай, если какой-то инструмент из 01–24 вспомнился смутно.

## Вопросы и ответы по уроку

### Почему хранилище заметок — карта, а не срез?

Карта map[int]Note держит номер заметки как ключ: удаление через delete не сдвигает остальные записи, проверка наличия — запятая-ok, чтение по номеру — прямое. Со срезом номер и позиция слиплись бы: после удаления середины заметки «съехали» бы, и номер перестал бы указывать на ту же запись. Недостаток карты — случайный порядок перебора — лечится сортировкой среза в List.

### Почему в песочнице нельзя по-настоящему вводить команды меню?

Интерпретатор песочницы живёт на странице браузера, где поток ввода stdin недоступен — оба движка не могут ждать нажатий клавиатуры. Поэтому механика меню проверена ранимым диспетчером на заранее заданном списке команд, а полная версия с fmt.Scan показана в неранимом блоке для локального запуска: go mod init notes, go run main.go.

### Почему поле CreatedAt в примерах int64, а не time.Time?

В настоящем Go время создания хранит time.Time из пакета time, и локальная версия урока показывает именно этот вариант. В песочнице резервный движок подменяет time.Now() сырым числом миллисекунд, которое меняется каждый запуск, а методы Format и Unix у него недоступны — вывод перестал бы быть воспроизводимым. Поэтому ранимые примеры используют int64 с фиксированной Unix-меткой: тип данных тот же, что и в базах данных, а честная оговорка в тексте объясняет замену.
