Перейти к содержимому

Практика 3. Инициализация и жизненный цикл объектов

Цель

Научиться проектировать безопасные пути создания значений и объектов, устранять дублирование между инициализаторами, правильно инициализировать иерархию классов, применять lazy и контролировать освобождение ресурсов.


Общие требования

  • выполняйте задания в отдельных файлах task_N.swift;
  • все примеры должны компилироваться Swift 6;
  • некорректный экземпляр не должен появляться в программе;
  • дополнительные инициализаторы должны делегировать основному;
  • добавьте assert для успешных и неуспешных сценариев;
  • не вызывайте deinit вручную — это невозможно и концептуально неверно.

Задание 1. Проверяемая модель UserAccount

Создайте неизменяемую структуру учётной записи.

struct UserAccount: Equatable {
let username: String
let email: String
let age: Int
init?(username: String, email: String, age: Int) {
fatalError("TODO")
}
}

Инварианты:

  • имя после удаления пробелов содержит не менее трёх символов;
  • email содержит одну @ и непустые части до и после неё;
  • возраст находится в диапазоне 14...120;
  • сохраняются нормализованные значения без пробелов по краям;
  • email приводится к нижнему регистру.
assert(UserAccount(username: " amina ", email: " USER@EXAMPLE.COM ", age: 20)
== UserAccount(username: "amina", email: "user@example.com", age: 20))
assert(UserAccount(username: "ab", email: "a@b.com", age: 20) == nil)
assert(UserAccount(username: "amina", email: "wrong", age: 20) == nil)
assert(UserAccount(username: "amina", email: "a@b.com", age: 10) == nil)

Не создавайте объект с временно некорректными полями и не исправляйте его после инициализации.


Задание 2. Несколько способов создания Temperature

Реализуйте значение температуры с единым основным путём инициализации.

struct Temperature: Equatable {
let celsius: Double
init?(celsius: Double) {
fatalError("TODO")
}
init?(fahrenheit: Double) {
fatalError("TODO")
}
init?(kelvin: Double) {
fatalError("TODO")
}
}

Требования:

  • температура не может быть ниже -273.15 °C;
  • инициализаторы Фаренгейта и Кельвина переводят значение и делегируют через self.init(celsius:);
  • проверки абсолютного нуля не дублируются;
  • допускается небольшая погрешность при сравнении результатов вычислений.
let freezingC = Temperature(celsius: 0)
let freezingF = Temperature(fahrenheit: 32)
let freezingK = Temperature(kelvin: 273.15)
assert(freezingC != nil)
assert(abs((freezingF?.celsius ?? 1) - 0) < 0.0001)
assert(abs((freezingK?.celsius ?? 1) - 0) < 0.0001)
assert(Temperature(kelvin: -1) == nil)

Задание 3. Designated и convenience initializer

Создайте класс документа с одним designated initializer и двумя convenience initializer.

import Foundation
final class Document {
let id: UUID
let title: String
private(set) var body: String
let createdAt: Date
init(id: UUID, title: String, body: String, createdAt: Date) {
fatalError("TODO")
}
convenience init(title: String, body: String) {
fatalError("TODO")
}
convenience init(title: String) {
fatalError("TODO")
}
func updateBody(_ newBody: String) {
fatalError("TODO")
}
}

Требования:

  • designated initializer получает все значения;
  • первый convenience initializer генерирует UUID() и Date();
  • второй создаёт документ с пустым телом через другой convenience initializer;
  • заголовок после удаления пробелов не должен быть пустым;
  • если заголовок некорректен, используйте осмысленное безопасное решение: измените все инициализаторы на проваливающиеся либо нормализуйте заголовок к заранее документированному значению "Без названия";
  • выбранное решение объясните.

Проверьте, что оба удобных пути в итоге создают полностью заполненный объект.


Задание 4. Инициализация иерархии транспорта

Дополните классы, соблюдая порядок первой фазы инициализации.

class Vehicle {
let identifier: String
private(set) var mileage: Int
init?(identifier: String, mileage: Int = 0) {
fatalError("TODO")
}
func drive(kilometers: Int) -> Bool {
fatalError("TODO")
}
}
final class ElectricCar: Vehicle {
let batteryCapacity: Int
private(set) var chargeLevel: Int
init?(
identifier: String,
mileage: Int = 0,
batteryCapacity: Int,
chargeLevel: Int = 100
) {
fatalError("TODO")
}
convenience init?(identifier: String, batteryCapacity: Int) {
fatalError("TODO")
}
func charge() {
fatalError("TODO")
}
}

Инварианты:

  • идентификатор непустой;
  • пробег неотрицательный;
  • ёмкость батареи положительная;
  • заряд находится в диапазоне 0...100;
  • подкласс присваивает собственные свойства до super.init;
  • convenience initializer делегирует designated initializer своего класса.

Добавьте проверки для каждой границы и письменно опишите порядок инициализации ElectricCar.


Задание 5. Ленивое построение отчёта

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

final class SalesReport {
let values: [Double]
private(set) var buildCount = 0
lazy var formatted: String = {
fatalError("TODO")
}()
init(values: [Double]) {
self.values = values
}
}

Строка должна содержать сумму, среднее и число элементов. Внутри замыкания увеличьте buildCount.

let report = SalesReport(values: [10, 20, 30])
assert(report.buildCount == 0)
let firstResult = report.formatted
assert(report.buildCount == 1)
let secondResult = report.formatted
assert(report.buildCount == 1)
assert(firstResult == secondResult)

Объясните, почему formatted должно быть var, даже если после первого вычисления результат больше не меняется.


Задание 6. Явное и резервное освобождение ресурса

Реализуйте имитацию соединения. Реальных сетевых запросов не требуется.

final class Connection {
let name: String
private(set) var isOpen: Bool
private let onClose: (String) -> Void
init(name: String, onClose: @escaping (String) -> Void) {
fatalError("TODO")
}
func send(_ message: String) -> Bool {
fatalError("TODO")
}
func close() {
fatalError("TODO")
}
deinit {
close()
}
}

Требования:

  • после создания соединение открыто;
  • пустое сообщение и отправка после закрытия возвращают false;
  • close() идемпотентен: повторный вызов не вызывает onClose снова;
  • deinit вызывает close() как страховку;
  • тест фиксирует количество закрытий без привязки к точной строке выполнения deinit.
var closedNames: [String] = []
do {
let connection = Connection(name: "api") { closedNames.append($0) }
assert(connection.send("ping"))
connection.close()
connection.close()
assert(closedNames == ["api"])
}
assert(closedNames == ["api"])

Затем повторите сценарий без явного close() и проверьте после завершения области do, что резервная очистка произошла один раз.


Дополнительное задание. required init

Создайте базовый класс Command с required init(name:) и два подкласса. Каждый подкласс должен корректно реализовать обязательный инициализатор и установить собственные значения по умолчанию. Создайте функцию, которая принимает Command.Type и создаёт команду через обязательный инициализатор.

func makeCommand(_ type: Command.Type, name: String) -> Command {
type.init(name: name)
}

Объясните, почему без required компилятор не мог бы гарантировать допустимость такого создания для любого подкласса.


Критерии оценки

РазделДоля
Задания 1–2: проверка данных и делегирование self.init25%
Задание 3: designated/convenience initializer15%
Задание 4: корректная инициализация иерархии20%
Задание 5: корректное использование lazy15%
Задание 6: жизненный цикл и идемпотентная очистка15%
Качество кода, проверки и объяснения10%

Дополнительное задание может компенсировать до 10% потерянных баллов.


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

  1. Почему все хранимые свойства должны получить значения до завершения init?
  2. В каком случае удобен init??
  3. Как избежать дублирования между несколькими инициализаторами структуры?
  4. Чем designated initializer отличается от convenience initializer?
  5. Почему подкласс присваивает свои свойства до super.init?
  6. Что означает двухфазная инициализация?
  7. Когда вычисляется lazy-свойство?
  8. Что определяет время жизни экземпляра класса?
  9. Почему close() должен быть идемпотентным?
  10. Почему очистку важного ресурса не стоит оставлять только на deinit?