Swift: краткий практический справочник

Краткое введение в повседневный Swift: объявления, типы, функции, коллекции, обработка ошибок, проектирование типов и конкурентность.

Основано на темах

The Swift Programming Language, Swift 6.4; примеры ниже написаны специально для этого конспекта.

Swift — компилируемый статически типизированный язык. Компилятор выводит типы из контекста, но проверяет их до запуска программы. Язык старается сделать обычный код безопасным: значения инициализируются до использования, выход за границы коллекции проверяется, отсутствие значения выражается явно через Optional, а ошибки можно обрабатывать типизированно.

1. Переменные, константы и типы

Используйте let, если значение не должно меняться; var — если оно будет переприсваиваться. Тип часто можно не указывать: Swift выведет его из начального значения. Тип можно записать явно после двоеточия.

let title = "Swift"           // String
var attempts: Int = 0
attempts += 1

let price = 19.95             // Double
let isAvailable = true        // Bool
let message = "\(title): \(attempts) attempt(s)"

String — строка, Int — целое число, Double — число с плавающей точкой, Bool — логическое значение.

Swift не выполняет неявное преобразование между разными числовыми типами:

let whole = 4
let fraction = 0.5
let total = Double(whole) + fraction

Условие в if должно иметь тип Bool; значения вроде 1 и 0 не считаются логическими.

2. Условия и циклы

let score = 82

if score >= 90 {
    print("отлично")
} else if score >= 60 {
    print("зачёт")
} else {
    print("нужно повторить")
}

for number in 1...3 {
    print(number)             // 1, 2, 3
}

for index in 0..<3 {
    print(index)              // 0, 1, 2
}

... включает обе границы, ..< не включает верхнюю. switch проверяет значение по нескольким шаблонам; каждый непустой вариант должен быть покрыт, обычно через default.

let statusCode = 404

switch statusCode {
case 200:
    print("успешно")
case 400..<500:
    print("ошибка запроса")
case 500..<600:
    print("ошибка сервера")
default:
    print("другой ответ")
}

Для раннего выхода из функции или цикла используйте guard:

func greeting(for name: String?) -> String {
    guard let name, !name.isEmpty else {
        return "Привет!"
    }
    return "Привет, \(name)!"
}

Как работает guard:

enum GuardExampleError: Error {
    case emptyMessage
    case missingRecipient
}

func send(message: String?, to userID: String?) throws {
    guard let message, !message.isEmpty else {
        throw GuardExampleError.emptyMessage
    }
    guard let userID else {
        throw GuardExampleError.missingRecipient
    }

    // message и userID здесь уже не Optional
    print("Send '\(message)' to \(userID)")
}

3. Функции и замыкания

Функция объявляется через func. Внешние метки аргументов делают вызов читаемым; _ убирает метку. Возвращаемый тип указывается после ->.

func convert(_ value: Double, toUnit unit: String = "km") -> String {
    "\(value) \(unit)"
}

let distance = convert(12.5, toUnit: "mi")
let defaultDistance = convert(12.5)

Замыкание — блок кода, который можно сохранить или передать функции. Методы коллекций map, filter и sorted принимают замыкания:

let values = [3, 1, 4, 2]
let squares = values.map { $0 * $0 }
let evenSquares = squares.filter { $0.isMultiple(of: 2) }
let ascending = values.sorted { left, right in left < right }

$0, $1 и далее — краткие имена параметров замыкания. Если логика сложная, дайте параметрам имена или вынесите код в отдельную функцию.

4. Строки и коллекции

Array хранит упорядоченный список, Set — уникальные значения без фиксированного порядка, Dictionary связывает ключи со значениями.

var tasks = ["прочитать", "повторить"]
tasks.append("попрактиковаться")

let uniqueTags: Set<String> = ["swift", "cli", "swift"]
let scores = ["Ada": 98, "Linus": 91]

if let adaScore = scores["Ada"] {
    print("Оценка Ada: \(adaScore)")
}

for task in tasks {
    print(task)
}

Индекс массива допустим только в диапазоне 0..<count; доступ по ключу словаря возвращает optional, поскольку ключа может не быть. Не обращайтесь к индексу, если коллекция может быть пустой.

Строки объединяют интерполяцией ("\(value)"). Многострочные литералы заключаются в """; граница отступа задаётся закрывающими кавычками.

let name = "Mina"
let note = """
    Участник: \(name)
    Статус: активен
    """

5. Optional и безопасная работа с отсутствием значения

Int? — это Optional<Int>: значение либо содержит Int, либо равно nil. Optional заставляет явно обработать ситуацию, когда данных нет.

let rawAge = "34"
let age = Int(rawAge)                 // Int?

if let age {
    print("Через год: \(age + 1)")
} else {
    print("Возраст не распознан")
}

if let временно разворачивает optional только в успешной ветке. Для значения по умолчанию используйте ??; для последовательного доступа — optional chaining ?..

let nickname: String? = nil
let displayName = nickname ?? "Гость"

struct Profile {
    let email: String?
}

let profile: Profile? = Profile(email: "user@example.org")
let email = profile?.email            // String?

Не используйте ! для обычного разворачивания: если значения нет, программа аварийно завершится. Принудительное разворачивание уместно только там, где инвариант гарантирован и проверен.

6. Ошибки

Для ожидаемых сбоев функция может объявлять throws. Вызов такой функции помечается try, а ошибка обрабатывается через do/catch.

enum InputError: Error {
    case empty
    case notANumber(String)
}

func parseScore(_ text: String) throws -> Int {
    guard !text.isEmpty else { throw InputError.empty }
    guard let score = Int(text) else {
        throw InputError.notANumber(text)
    }
    return score
}

do {
    let score = try parseScore("87")
    print("Баллы: \(score)")
} catch InputError.empty {
    print("Введите значение")
} catch {
    print("Не удалось разобрать ввод: \(error)")
}

Проброс ошибки через несколько функций

Если функция не может (или не должна) решать, как обрабатывать ошибку, она просто помечается throws и пробрасывает ошибку выше по цепочке вызовов.

enum MessagingError: Error {
    case missingRecipient
    case transportOffline
}

func validateRecipient(_ id: String?) throws -> String {
    guard let id, !id.isEmpty else {
        throw MessagingError.missingRecipient
    }
    return id
}

func buildPayload(text: String, recipientID: String?) throws -> [String: String] {
    let recipient = try validateRecipient(recipientID)  // ошибка идёт дальше
    return ["to": recipient, "text": text]
}

func performRequest(payload: [String: String]) throws {
    let online = false
    guard online else { throw MessagingError.transportOffline }
    print("Send payload: \(payload)")
}

func sendMessage(text: String, recipientID: String?) throws {
    let payload = try buildPayload(text: text, recipientID: recipientID)
    try performRequest(payload: payload)                // проброс на уровень выше
}

do {
    try sendMessage(text: "Hi", recipientID: nil)
} catch MessagingError.missingRecipient {
    print("Укажите получателя")
} catch MessagingError.transportOffline {
    print("Сеть недоступна, повторите позже")
} catch {
    print("Неожиданная ошибка: \(error)")
}

try? превращает ошибку в nil, если достаточно знать только «получилось/не получилось». try! отключает обработку ошибки и подходит лишь для гарантированно безопасного вызова.

7. Свои типы: struct, enum, class

struct — тип-значение: при присваивании или передаче копируется само значение. Это хороший выбор по умолчанию для моделей данных. class — ссылочный тип: несколько переменных могут указывать на один экземпляр; классы поддерживают наследование.

struct Book {
    let title: String
    var page: Int

    mutating func turnPage() {
        page += 1
    }
}

var book = Book(title: "Практика Swift", page: 1)
book.turnPage()

mutating разрешает методу структуры изменять её свойства. let-экземпляр структуры неизменяем целиком.

Перечисление задаёт конечный набор вариантов. Варианты могут содержать значения; switch позволяет обработать их по типобезопасным шаблонам.

import Foundation

enum DownloadState {
    case idle
    case loading(progress: Double)
    case finished(URL)
    case failed(message: String)
}

func describe(_ state: DownloadState) -> String {
    return switch state {
    case .idle:
        "ожидание"
    case .loading(let progress):
        "загрузка: \(Int(progress * 100))%"
    case .finished(let url):
        "готово: \(url.lastPathComponent)"
    case .failed(let message):
        "ошибка: \(message)"
    }
}

Здесь URL требует import Foundation.

8. Протоколы, расширения и обобщения

Протокол описывает требования к типу. Тип принимает эти требования через conformance; общий код может работать с любым подходящим типом.

protocol IdentifiableItem {
    var id: String { get }
    var displayName: String { get }
}

struct Note: IdentifiableItem {
    let id: String
    let displayName: String
}

func printItem<T: IdentifiableItem>(_ item: T) {
    print("\(item.displayName) [\(item.id)]")
}

T — параметр типа. Ограничение T: IdentifiableItem гарантирует, что у значения есть нужные свойства. Так сохраняется конкретный тип без приведения к небезопасному Any.

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

import Foundation

extension String {
    var isBlank: Bool {
        trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    }
}

Этот пример также требует import Foundation.

9. Ссылки и управление памятью

Классы управляются ARC (Automatic Reference Counting): объект освобождается, когда на него больше нет сильных ссылок. Сильные циклы ссылок могут удерживать объекты в памяти. Для обратных ссылок применяют weak или unowned с учётом их разных гарантий (weak становится nil, поэтому объявляется optional).

final class Controller {
    var onClose: (() -> Void)?

    func configure() {
        onClose = { [weak self] in
            self?.close()
        }
    }

    private func close() {
        print("Закрыто")
    }
}

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

10. Асинхронность и конкурентность

async помечает асинхронную функцию, await — точку, где выполнение может приостановиться, пока функция получает результат. Асинхронный вызов обычно не блокирует поток.

func loadTitle() async throws -> String {
    try await Task.sleep(for: .milliseconds(20))
    return "Готово"
}

func showTitle() async {
    do {
        print(try await loadTitle())
    } catch {
        print("Задача отменена или завершилась с ошибкой")
    }
}

Несколько независимых задач можно запустить параллельно через async let; результат читается с await. Для динамического количества дочерних задач используют task group. Структурированная конкурентность связывает время жизни дочерних задач с родительской задачей.

actor защищает своё изменяемое состояние: доступ к его изолированным свойствам из другого контекста требует await.

actor Counter {
    private var value = 0

    func increment() {
        value += 1
    }

    func currentValue() -> Int {
        value
    }
}

func readCounter() async {
    let counter = Counter()
    await counter.increment()
    let value = await counter.currentValue()
    print(value)
}

В Swift 6 компилятор строже проверяет передачу данных между конкурентными контекстами. Тип Sendable означает, что значение безопасно передавать между задачами; value-типы с безопасными полями обычно проще сделать потокобезопасными, чем разделяемое изменяемое состояние.

Ниже — самые полезные практические паттерны async-кода.

10.1 Параллельные независимые запросы (async let)

Если операции не зависят друг от друга, запускайте их одновременно:

func loadProfileName() async throws -> String {
    try await Task.sleep(for: .milliseconds(30))
    return "Alex"
}

func loadUnreadCount() async throws -> Int {
    try await Task.sleep(for: .milliseconds(40))
    return 7
}

func loadDashboard() async throws -> String {
    async let name = loadProfileName()
    async let unread = loadUnreadCount()

    // await здесь дождётся обоих результатов
    return "User: \(try await name), unread: \(try await unread)"
}

async let хорошо подходит для фиксированного числа задач. Если задач динамически много — используйте TaskGroup.

10.2 Динамический fan-out (withThrowingTaskGroup)

TaskGroup позволяет создать переменное число дочерних задач и собрать их результаты по мере готовности.

func fetchValue(id: Int) async throws -> Int {
    try await Task.sleep(for: .milliseconds(10))
    return id * id
}

func loadAll(ids: [Int]) async throws -> [Int] {
    try await withThrowingTaskGroup(of: Int.self, returning: [Int].self) { group in
        for id in ids {
            group.addTask {
                try await fetchValue(id: id)
            }
        }

        var result: [Int] = []
        for try await value in group {
            result.append(value)
        }
        return result.sorted()
    }
}

Если одна дочерняя задача бросит ошибку, withThrowingTaskGroup пробросит её наружу, а остальные дочерние задачи будут отменены.

10.3 Отмена задач (cancellation)

Отмена кооперативная: задача должна сама проверять флаг отмены и корректно завершаться.

func longOperation() async throws -> Int {
    var sum = 0
    for i in 1...10_000 {
        if i.isMultiple(of: 500) {
            try Task.checkCancellation()   // бросает CancellationError
            try await Task.sleep(for: .milliseconds(1))
        }
        sum += i
    }
    return sum
}

let task = Task {
    try await longOperation()
}

task.cancel()

defer и withTaskCancellationHandler полезны для очистки ресурсов при досрочном завершении.

10.4 UI-обновления и @MainActor

Код, который трогает состояние UI-модели, лучше изолировать на главном акторе.

@MainActor
final class ViewModel {
    private(set) var title = "Loading..."

    func refresh() async {
        do {
            let text = try await loadTitle()
            title = text
        } catch {
            title = "Error"
        }
    }
}

@MainActor гарантирует последовательный доступ к этому состоянию из конкурентного кода и упрощает reasoning о гонках.

10.5 Потоки данных (AsyncSequence)

for await позволяет читать значения постепенно, как поток событий.

struct CounterStream: AsyncSequence {
    typealias Element = Int

    struct Iterator: AsyncIteratorProtocol {
        var current = 1
        mutating func next() async -> Int? {
            guard current <= 3 else { return nil }
            defer { current += 1 }
            try? await Task.sleep(for: .milliseconds(20))
            return current
        }
    }

    func makeAsyncIterator() -> Iterator { Iterator() }
}

func consumeStream() async {
    for await value in CounterStream() {
        print("event:", value)
    }
}

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

11. Инициализация и деинициализация

Инициализатор init подготавливает тип к использованию. Для структур это часто главный способ гарантировать инварианты объекта. У классов дополнительно есть deinit, который вызывается перед освобождением экземпляра ARC.

import Foundation

struct Temperature {
    let celsius: Double

    init(celsius: Double) {
        self.celsius = celsius
    }

    init(fahrenheit: Double) {
        self.celsius = (fahrenheit - 32) * 5 / 9
    }
}

final class FileLogger {
    private let handle: FileHandle

    init?(path: String) {
        guard FileManager.default.createFile(atPath: path, contents: nil) else {
            return nil
        }
        guard let h = FileHandle(forWritingAtPath: path) else { return nil }
        self.handle = h
    }

    func write(_ line: String) throws {
        try handle.write(contentsOf: Data((line + "\n").utf8))
    }

    deinit {
        try? handle.close()
    }
}

init? — проваливающийся инициализатор (может вернуть nil), полезен для валидации входных данных.

12. Type Casting (is, as?, as!)

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

class MediaItem {
    let title: String
    init(title: String) { self.title = title }
}

final class Movie: MediaItem {
    let director: String
    init(title: String, director: String) {
        self.director = director
        super.init(title: title)
    }
}

final class Song: MediaItem {
    let artist: String
    init(title: String, artist: String) {
        self.artist = artist
        super.init(title: title)
    }
}

let library: [MediaItem] = [
    Movie(title: "Blade Runner", director: "Ridley Scott"),
    Song(title: "Nude", artist: "Radiohead")
]

for item in library {
    if let movie = item as? Movie {
        print("Фильм: \(movie.title), режиссёр \(movie.director)")
    } else if let song = item as? Song {
        print("Трек: \(song.title), артист \(song.artist)")
    }
}

13. Вложенные типы (Nested Types)

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

struct Payment {
    enum Method {
        case cash
        case card(last4: String)
    }

    struct Receipt {
        let id: String
        let total: Double
    }

    let method: Method
    let amount: Double
    let receipts: [Receipt]
}

let p = Payment(
    method: .card(last4: "1024"),
    amount: 49.9,
    receipts: [
        .init(id: "r-001", total: 29.9),
        .init(id: "r-002", total: 20.0)
    ]
)

// Снаружи nested-тип пишется через полное имя:
let allReceipts: [Payment.Receipt] = p.receipts

Так внешнее API остаётся компактным, а связанные сущности не «засоряют» глобальное пространство имён.

14. Opaque и Boxed protocol types (some и any)

some Protocol скрывает конкретный возвращаемый тип от вызывающего кода, но оставляет его фиксированным для конкретной функции. any Protocol хранит значение неизвестного конкретного типа (type erasure, «коробка»).

protocol Shape {
    func area() -> Double
}

struct Square: Shape {
    let side: Double
    func area() -> Double { side * side }
}

func makeUnitShape() -> some Shape {
    Square(side: 1)
}

func printArea(of shape: any Shape) {
    print(shape.area())
}

let unit = makeUnitShape()      // конкретный тип скрыт
printArea(of: unit)             // принимаем "любую фигуру"

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

15. Memory Safety и эксклюзивный доступ

Swift запрещает одновременный конфликтующий доступ к памяти: например, две записи в одно место или чтение+запись, перекрывающиеся по времени.

var balance = 100

func deposit(_ amount: Int, to value: inout Int) {
    value += amount
}

deposit(20, to: &balance)       // OK

Типичный анти-паттерн — одновременно передавать одну переменную как inout в два параметра одной функции (или читать и писать её же внутри пересекающихся областей). Компилятор и рантайм отслеживают такие конфликты и останавливают опасный код.

16. Продвинутые операторы (фрагмент)

Swift поддерживает побитовые операции и пользовательские операторы.

let mask: UInt8 = 0b0000_1111
let value: UInt8 = 0b1010_1100
let lowBits = value & mask              // 0b0000_1100
let shifted = value >> 2                // 0b0010_1011

Пользовательский оператор стоит вводить только когда это реально улучшает читаемость доменной модели:

infix operator **: MultiplicationPrecedence

func ** (lhs: Double, rhs: Double) -> Double {
    pow(lhs, rhs)
}

let x = 2.0 ** 3.0                      // 8.0

17. Макросы (обзор)

Макросы генерируют Swift-код на этапе компиляции и уменьшают шаблонный код. В прикладном коде чаще встречается потребление уже готовых макросов, чем написание своих.

@attached(member, names: named(init))
macro AutoInit()

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

18. Language Reference: короткий ориентир

Полный справочник языка значительно формальнее Language Guide. Ниже минимум, который полезно знать в повседневной работе.

18.1 Lexical Structure

Лексика определяет, как исходник разбивается на токены: идентификаторы, литералы, операторы, комментарии, пробелы.

let café = "unicode identifier"
let hex = 0xFF
let scientific = 1.2e3

18.2 Types / Expressions / Statements

let pair: (Int, String) = (1, "one")
let numbers: [Int] = [1, 2, 3]

func execute(_ body: () -> Void) {
    defer { print("done") }
    body()
}

18.3 Declarations / Attributes / Patterns

@discardableResult
func log(_ message: String) -> Int {
    print(message)
    return message.count
}

let point = (x: 2, y: 5)
switch point {
case (0, 0):
    print("origin")
case let (x, y) where x == y:
    print("diagonal \(x)")
default:
    print("(\(point.x), \(point.y))")
}

18.4 Generic Parameters and Arguments

Обобщения объявляют параметры типа (<T>) и ограничения (where), а при вызове компилятор подставляет конкретные аргументы типа.

func firstMatch<T>(in values: [T], where predicate: (T) -> Bool) -> T? {
    for v in values where predicate(v) {
        return v
    }
    return nil
}

let match = firstMatch(in: [10, 15, 20]) { $0.isMultiple(of: 3) } // 15

18.5 Summary of Grammar

Грамматика в книге — формальная EBNF-нотация для полного синтаксиса Swift.

Практически она нужна, когда вы:

19. Пакеты, зависимости, форматирование и Makefile

Даже в небольшом CLI-проекте лучше сразу держать единый workflow: зависимости через SwiftPM, одинаковое форматирование и повторяемые команды сборки.

19.1 Как добавлять dependencies через SwiftPM

В Package.swift зависимость объявляется в секции dependencies, а затем подключается к нужному target в targets[].dependencies.

// swift-tools-version: 6.4
import PackageDescription

let package = Package(
    name: "demo-cli",
    platforms: [.macOS(.v13)],
    products: [
        .executable(name: "demo-cli", targets: ["demo-cli"])
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.2")
    ],
    targets: [
        .executableTarget(
            name: "demo-cli",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser")
            ]
        )
    ]
)

Базовые команды:

swift package resolve        # резолв зависимостей и запись Package.resolved
swift build                  # сборка
swift test                   # тесты
swift package update         # обновить версии в допустимых границах

Практика: – фиксируйте минимально нужную версию (from:), не «слишком новую заранее»; – коммитьте Package.resolved для воспроизводимых сборок; – после добавления dependency сразу проверяйте swift build и swift test.

19.2 Форматирование и линтинг (swift-format)

Обычно используют swift-format c конфигом в корне проекта.

Пример .swift-format:

{
  "version": 1,
  "indentation": { "spaces": 4 },
  "lineLength": 120
}

Полезные команды:

swift format lint --strict --recursive Sources Tests
swift format format --in-place --recursive Sources Tests

19.3 Пример Makefile для повседневной работы

Makefile убирает «магические» команды из головы и стандартизирует CI/локалку.

.PHONY: build test lint fmt check release clean docs

build:
	swift build

test:
	swift test

lint:
	swift format lint --strict --recursive Sources Tests

fmt:
	swift format format --in-place --recursive Sources Tests

check: build test lint

release:
	swift build -c release

docs:
	swift package --allow-writing-to-package-directory generate-docc-reference

clean:
	swift package clean

Минимальный цикл перед коммитом:

make check

Для релиза:

make release

Если проект использует дополнительные инструменты (например, swiftlint), их добавляют отдельными целями (lint-swiftlint, lint-all) без смешивания с базовым swift format lint.

20. Как правильно организовать структуру проекта

Хорошая структура проекта решает три задачи: понятная навигация, независимое тестирование и безопасное расширение без «комка» зависимостей.

20.1 Базовая структура SwiftPM-проекта

Для CLI и библиотек удобен стандарт SwiftPM:

my-project/
├─ Package.swift
├─ Package.resolved
├─ README.md
├─ Sources/
│  ├─ AppCLI/                  # executable target (точка входа)
│  │  ├─ main.swift            # или @main type
│  │  ├─ Commands/
│  │  │  ├─ RootCommand.swift
│  │  │  ├─ DeckCommand.swift
│  │  │  └─ CardCommand.swift
│  │  └─ Support/
│  │     └─ Output.swift
│  └─ Core/                    # library target с бизнес-логикой
│     ├─ Domain/
│     │  ├─ Deck.swift
│     │  └─ Card.swift
│     ├─ Services/
│     │  ├─ SchedulerService.swift
│     │  └─ ReviewService.swift
│     └─ Persistence/
│        ├─ Database.swift
│        └─ Repositories/
├─ Tests/
│  ├─ CoreTests/
│  └─ AppCLITests/
├─ docs/
└─ Makefile

Идея: AppCLI занимается только парсингом аргументов/выводом, а вся логика живёт в Core. Это упрощает тесты и повторное использование кода.

20.2 Принцип «слои и зависимости в одну сторону»

Хорошее правило для модулей:

Такой граф зависимостей предотвращает циклы и делает замену инфраструктуры (например, SQLite → Postgres, CLI → HTTP API) локальной задачей.

20.3 Группировка по feature vs по техническим слоям

Есть два рабочих подхода:

Компромисс: в Core держать feature-папки, а внутри каждой — мини-слои.

Sources/Core/
├─ Deck/
│  ├─ Deck.swift
│  ├─ DeckService.swift
│  └─ DeckRepository.swift
└─ Review/
   ├─ Review.swift
   ├─ ReviewService.swift
   └─ ReviewRepository.swift

20.4 Тестовая структура

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

20.5 Именование файлов и типов

20.6 Что часто ломает структуру

Если сомневаетесь, выносите не по «красоте», а по границам изменений: код, который изменяется вместе, должен лежать рядом.

20.7 Как AppCLI видит Core и что делать с одинаковыми именами

AppCLI «видит» Core, только если зависимость явно объявлена в Package.swift, а в коде есть import Core.

// Package.swift (фрагмент)
targets: [
    .target(name: "Core"),
    .executableTarget(
        name: "AppCLI",
        dependencies: ["Core"]
    )
]
// Sources/AppCLI/main.swift
import Core

let service = DeckService()

Важно: файлы Deck.swift и Card.swift внутри одного и того же target образуют один namespace модуля Core. Поэтому две глобальные функции с одним и тем же именем и сигнатурой вызовут ошибку компиляции, даже если лежат в разных файлах.

Плохой пример (конфликт):

// Deck.swift
func parse(_ text: String) -> Int { 1 }

// Card.swift
func parse(_ text: String) -> Int { 2 } // redeclaration error

Правильные варианты:

enum DeckParser {
    static func parse(_ text: String) -> Int { 1 }
}

enum CardParser {
    static func parse(_ text: String) -> Int { 2 }
}

let a = DeckParser.parse("...")
let b = CardParser.parse("...")

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

21. Подключение внешних lib*.so

На Linux в Swift есть два основных сценария:

21.1 Рекомендуемый путь: systemLibrary + modulemap

Если у вас есть libmylib.so и заголовок mylib.h, подключайте библиотеку как C-модуль.

Package.swift (пример):

// swift-tools-version: 6.4
import PackageDescription

let package = Package(
    name: "demo",
    targets: [
        .systemLibrary(
            name: "CMyLib",
            pkgConfig: "mylib"
        ),
        .executableTarget(
            name: "app",
            dependencies: ["CMyLib"],
            linkerSettings: [
                .unsafeFlags(["-L/opt/mylib/lib"]),
                .linkedLibrary("mylib")
            ]
        )
    ]
)

Sources/CMyLib/module.modulemap:

module CMyLib [system] {
  header "/opt/mylib/include/mylib.h"
  link "mylib"
  export *
}

Использование в Swift:

import CMyLib

let rc = mylib_init()
print("init rc =", rc)

Что важно: – библиотека должна иметь C ABI; – если исходник на C++, обычно нужен C-wrapper (extern "C"); – рантайм должен находить .so (через LD_LIBRARY_PATH, rpath или установку в системный путь линковщика).

21.2 Динамическая загрузка в рантайме: dlopen/dlsym

Нужна, если библиотека опциональна или выбирается по конфигу во время запуска.

import Glibc

typealias InitFn = @convention(c) () -> Int32

let handle = dlopen("/opt/mylib/lib/libmylib.so", RTLD_NOW)
guard handle != nil else {
    fatalError(String(cString: dlerror()))
}
defer { dlclose(handle) }

guard let sym = dlsym(handle, "mylib_init") else {
    fatalError(String(cString: dlerror()))
}

let fn = unsafeBitCast(sym, to: InitFn.self)
let rc = fn()
print("init rc =", rc)

Минусы этого подхода: меньше проверок на этапе компиляции и выше риск ошибок в подписи функции/символах. Поэтому для обычного проекта предпочтителен systemLibrary.

22. Один проект: lib.so + несколько утилит

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

22.1 Пример Package.swift

// swift-tools-version: 6.4
import PackageDescription

let package = Package(
    name: "my-stack",
    products: [
        .library(name: "Core", type: .dynamic, targets: ["Core"]), // libCore.so
        .executable(name: "server", targets: ["Server"]),
        .executable(name: "client", targets: ["Client"])
    ],
    targets: [
        .target(name: "Core"),
        .executableTarget(name: "Server", dependencies: ["Core"]),
        .executableTarget(name: "Client", dependencies: ["Core"])
    ]
)

Сборка:

swift build -c release
swift build -c release --product server
swift build -c release --product client

По умолчанию артефакты лежат в .build/..., а не в ./lib и ./bin.

22.2 Как получить layout lib/ + bin/

Обычно добавляют install-цель в Makefile, которая копирует артефакты после swift build -c release.

.PHONY: release install

release:
	swift build -c release

install: release
	mkdir -p ./lib ./bin
	cp ./.build/release/libCore.so ./lib/
	cp ./.build/release/server ./bin/
	cp ./.build/release/client ./bin/

Чтобы server/client нашли libCore.so при запуске, используйте один из вариантов:

22.3 Рекомендуемая структура для такого проекта

my-stack/
├─ Package.swift
├─ Sources/
│  ├─ Core/
│  │  ├─ Domain/
│  │  ├─ Services/
│  │  └─ Persistence/
│  ├─ Server/
│  │  └─ main.swift
│  └─ Client/
│     └─ main.swift
├─ Tests/
│  ├─ CoreTests/
│  ├─ ServerTests/
│  └─ ClientTests/
├─ bin/                        # install-артефакты (не из git)
├─ lib/                        # install-артефакты (не из git)
└─ Makefile

Core держит бизнес-логику и контракты, Server/Client — только transport, CLI/сеть и composition root. Это уменьшает дублирование и упрощает тестирование.

23. Парсинг аргументов CLI: команды и подкоманды

Для production CLI в Swift обычно используют swift-argument-parser: он даёт типобезопасные аргументы, автогенерацию --help и понятную структуру команд.

23.1 Базовый шаблон команды

import ArgumentParser

@main
struct App: ParsableCommand {
    static let configuration = CommandConfiguration(
        commandName: "app",
        abstract: "Demo CLI"
    )

    @Argument(help: "Имя пользователя")
    var name: String

    @Option(name: .shortAndLong, help: "Количество повторов")
    var count: Int = 1

    @Flag(name: .shortAndLong, help: "Подробный вывод")
    var verbose = false

    mutating func run() throws {
        guard count > 0 else {
            throw ValidationError("--count must be > 0")
        }
        for _ in 0..<count {
            print(verbose ? "Hello, \(name) [verbose]" : "Hello, \(name)")
        }
    }
}

Здесь: – @Argument — позиционный аргумент; – @Option — параметр со значением (--count 3); – @Flag — булев переключатель (--verbose).

23.2 Иерархия команд и подкоманд

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

import ArgumentParser

@main
struct Recall: ParsableCommand {
    static let configuration = CommandConfiguration(
        commandName: "recall",
        abstract: "Flashcards CLI",
        subcommands: [Deck.self, Card.self]
    )
}

struct Deck: ParsableCommand {
    static let configuration = CommandConfiguration(
        abstract: "Manage decks",
        subcommands: [DeckAdd.self, DeckList.self, DeckDelete.self]
    )
}

struct DeckAdd: ParsableCommand {
    static let configuration = CommandConfiguration(
        commandName: "add",
        abstract: "Add deck"
    )

    @Argument(help: "Deck name")
    var name: String

    mutating func run() throws {
        print("add deck:", name)
    }
}

Так пользователь получает предсказуемый UX: – recall deck add rust – recall deck list – recall card add ...

23.3 Где делать валидацию аргументов

Есть два основных места:

struct ImportCommand: ParsableCommand {
    @Option(help: "Path to file")
    var file: String

    func validate() throws {
        guard file.hasSuffix(".txt") else {
            throw ValidationError("Only .txt files are supported")
        }
    }

    mutating func run() throws {
        print("Import from", file)
    }
}

23.4 Рекомендуемая структура файлов для CLI-команд

Sources/AppCLI/
├─ main.swift
├─ Commands/
│  ├─ RootCommand.swift
│  ├─ Deck/
│  │  ├─ DeckCommand.swift
│  │  ├─ DeckAddCommand.swift
│  │  ├─ DeckListCommand.swift
│  │  └─ DeckDeleteCommand.swift
│  └─ Card/
│     ├─ CardCommand.swift
│     └─ CardAddCommand.swift
└─ Support/
   ├─ Output.swift
   └─ Parsing/
      └─ CommonOptions.swift

Правило: корневые команды/подкоманды живут в Commands, переиспользуемые типы аргументов и опций — в Support.

24. Mock в тестах: как подменять зависимости

В Swift чаще всего делают mock через протокол и подставную реализацию в тесте. Это позволяет тестировать бизнес-логику без реальной БД, сети или файловой системы.

24.1 Базовый паттерн: Protocol + Service + Mock

protocol DeckRepository {
    func save(name: String) throws
}

enum DeckError: Error {
    case emptyName
}

struct DeckService {
    let repository: DeckRepository

    func createDeck(name: String) throws {
        guard !name.isEmpty else { throw DeckError.emptyName }
        try repository.save(name: name)
    }
}

Mock-реализация для теста:

final class MockDeckRepository: DeckRepository {
    var savedNames: [String] = []
    var saveError: Error?

    func save(name: String) throws {
        if let saveError { throw saveError }
        savedNames.append(name)
    }
}

Пример теста:

import Testing
@testable import Core

@Test
func createDeck_savesName() throws {
    let mock = MockDeckRepository()
    let service = DeckService(repository: mock)

    try service.createDeck(name: "swift")

    #expect(mock.savedNames == ["swift"])
}

24.2 Mock, Stub, Spy — в чём разница

На практике одна тестовая реализация может совмещать роли (например, MockDeckRepository выше — и stub, и spy).

24.3 Правила для удобных тестов

25. Как правильно работать с тестами

Хорошие тесты в Swift-проекте — это не только assertions, но и понятная структура каталогов, корректные импорты и предсказуемый запуск.

25.1 Размещение тестов

Обычно структура повторяет Sources:

Sources/
├─ Core/
│  ├─ Deck/
│  └─ Review/
└─ AppCLI/

Tests/
├─ CoreTests/
│  ├─ Deck/
│  │  └─ DeckServiceTests.swift
│  └─ Review/
│     └─ ReviewServiceTests.swift
├─ AppCLITests/
│  └─ DeckCommandTests.swift
└─ TestSupport/
   ├─ Mocks/
   ├─ Builders/
   └─ Fixtures/

Плюс такого layout: по имени production-файла сразу понятно, где лежит тест.

25.2 Импорт модулей в тестах

Основные варианты:

import Testing
@testable import Core

@Test
func createDeck_rejectsEmptyName() {
    let service = DeckService(repository: MockDeckRepository())
    #expect(throws: DeckError.emptyName) {
        try service.createDeck(name: "")
    }
}

@testable работает, когда модуль собран с -enable-testing (для swift test это обычно уже так).

25.3 Именование тестов

Это упрощает чтение отчёта CI и ускоряет локальную диагностику.

25.4 TestSupport: где держать mocks/fixtures/builders

Не переносите тестовые helper-ы в Sources, если они не нужны production-коду.

25.5 Запуск и изоляция тестов

Минимальные команды:

swift test
swift test --filter DeckServiceTests
swift test --filter createDeck_emptyName_throws

Рекомендации: – юнит-тесты не должны зависеть от сети/времени/случайности без контроля; – для времени используйте внедрение Clock/DateProvider через протокол; – для I/O используйте временные директории и очищайте их в defer.

25.6 Частые проблемы и как избежать

26. defer: гарантированная очистка и финализация

defer откладывает выполнение блока кода до выхода из текущего scope (функции/метода/замыкания). Это удобно, когда cleanup должен произойти при любом исходе: успешный return, ранний throw или выход по условию.

26.1 Базовая идея

func process() {
    print("start")
    defer { print("finish") }
    print("work")
}

process()
// start
// work
// finish

defer выполняется один раз, прямо перед выходом из scope.

26.2 defer + ошибки

При throw отложенный блок всё равно выполнится:

enum WorkError: Error { case failed }

func runWork(shouldFail: Bool) throws {
    defer { print("cleanup") }
    if shouldFail { throw WorkError.failed }
    print("success")
}

Это делает defer безопасным местом для закрытия ресурсов, unlock и rollback.

26.3 Порядок нескольких defer

Если defer несколько, они выполняются в порядке LIFO (последний добавленный выполняется первым).

func orderDemo() {
    defer { print("third") }
    defer { print("second") }
    defer { print("first") }
    print("body")
}

orderDemo()
// body
// first
// second
// third

26.4 Типичные use-cases

import Foundation

func writeTempFile() throws {
    let path = "/tmp/swift-defer-demo.txt"
    FileManager.default.createFile(atPath: path, contents: nil)
    guard let handle = FileHandle(forWritingAtPath: path) else { return }
    defer { try? handle.close() }  // выполнится всегда

    try handle.write(contentsOf: Data("hello\\n".utf8))
}

Главное правило: в defer помещайте только завершение/cleanup, а не основную бизнес-логику.

27. Практические правила

Этот конспект остаётся кратким, но теперь покрывает не только базовый Language Guide, а также ключевые темы Language Reference. Для полной глубины и всех edge-cases смотрите The Swift Programming Language и документацию Swift.