swift

Классы в Swift. Отличия от структур

Введение

В Swift основные строительные блоки для создания типов данных — это классы и структуры.

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

Понимание этих различий — ключ к написанию эффективного и предсказуемого кода.

Общие черты классов и структур

Прежде чем углубляться в различия, посмотрим, что у них общего.

И классы, и структуры предоставляют схожий интерфейс для определения новых типов данных.

Общая черта Описание
Свойства Могут хранить значения (переменные и константы)
Методы Могут содержать функции, которые работают со свойствами и не только
Инициализаторы Могут иметь специальные методы init для настройки начального состояния
Расширения Можно расширять функциональность с помощью extension
Протоколы Могут соответствовать протоколам, реализуя их требования

Пример определения класса и структуры выглядит схоже:

/// Класс
class PersonClass {
    var name: String
    var age: Int
    
    /// Обязательно указать инициализатор
    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
    
    func greet() {
        print("Привет, меня зовут \(name)")
    }
}

/// Структура
struct PersonStruct {
    var name: String
    var age: Int
    
    /// Стандартный инициализатор (name: String, age: Int) создан автоматически
    
    func greet() {
        print("Привет, меня зовут \(name)")
    }
}

Главные отличия классов от структур

Теперь рассмотрим ключевые различия, которые определяют поведение этих типов.

1. Ссылочный тип (Классы) против типа-значение (Структуры)

Давайте рассмотрим фундаментальное различие.

Пример с классом:

class Car {
	/// Модель автомобиля 
    var model: String
    
    /// Инициализатор
    init(model: String) { 
       self.model = model
    }
}

var car1 = Car(model: "Tesla")
var car2 = car1

// Меняем модель только у car2
car2.model = "BMW"

// ОБРАТИТЕ ВНИМАНИЕ – НАЗВАНИЕ МОДЕЛИ ИЗМЕНИЛОСЬ У ОБЕИХ МАШИН
print(car1.model) // BMW
print(car2.model) // BMW

ЭТО И ЕСТЬ ФУНДАМЕНТАЛЬНОЕ ОТЛИЧИЕ, ОБЯЗАТЕЛЬНО УЯСНИТЕ

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

Когда мы сказали, что var car2 = car1 фактически мы не создали новый независимый объект, мы просто сказали, что теперь у нас есть объект car2 который ссылается на ту же ячейку в памяти, что и car1

Если мы изменим car1 или car2 то копия другого объекта так же претерпит изменения так как они ссылаются на один и тот же адрес в памяти.

Что значит “ссылаются на один и тот же адрес в памяти” ?

Дело в том, что когда мы создаём класс – ему выделяется место в памяти (представьте себе сейф, с кучей ячеек) это место (ячейка) обозначается неким идентификатором (ссылкой), по которой мы можем найти этот созданный объект.

Скажем, у нас есть созданный car1 и хранится он по адресу 0x6000001436a0 и теперь, когда мы создадим сколь угодно много объектов на основе car1 (var car2 = car1, var car3 = car2…) все они будут ссылаться на один и тот же адрес памяти и делить между собой одну ссылку 0x6000001436a0, а следовательно если мы будем менять любой из них – изменятся все.

Поэтому, когда мы говорим, что например car2.model = "УАЗ" это значит, что все остальные car1, car3 и так далее тоже изменят свою модель, даже несмотря на то, что мы их и не трогали.

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

Давайте представим, что пользователь заполняет данные о себе на трёх экранах:

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

В таком случае, где бы мы не поменяли данные у нашего реального объекта userLoh он всегда будет иметь актуальную информацию.

При переходе с одного экрана на другой, у нас будут меняться классы этих экранов (SignUpStep1ViewModel(user: userLoh), SignUpStep2ViewModel(user: userLoh)…), но пользователь всегда будет одним и тем же, на каком из экранов мы бы его не изменили – информация всегда будет актуальна, что очень удобно.

Пример со структурой:

struct Car {
    var model: String
}

var car1 = Car(model: "Tesla")
var car2 = car1
car2.model = "BMW"

/// Car1 и Car2 полностью независимые копии 
print(car1.model) // Tesla
print(car2.model) // BMW

Здесь car2 — независимая копия. Изменения в одной не влияют на другую.

Как вы, я надеюсь, заметили – в случае со структурой объект car2 полностью независим от car1, впрочем как и car1 полностью независим от car2.

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

Дело в том, что структуры имеют иное поведение, они хранятся в совершенно другом месте нежели классы.

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

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

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

К счастью – в подавляющем большинстве случаев всё происходит автоматически за счёт ARC (Автоматическая система, которая удаляет ненужные классы, как только на них больше никто не ссылается)

Давайте рассмотрим пример:

Представьте, что в комнате сидят четыре человека и играют в Мафию. В колоде только одна карта Мафии.

Колода это класс.

В адекватном мире, мы сначало вручим колоду в руки Пете, затем Маше, потом Васе и наконец дойдём до Еблантия. Каждый из них по очереди достанет карту – проблем нет.

Но давайте представим, что мы живём в мире, где возможно многопоточное взятие кард из колоды.

У нас есть четыре потока (Петя, Маша, Вася, Еблантий) – теперь они могут выполнять работу одновременно.

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

Если они только посмотрят на неё и не будут менять – проблем нет, но представьте, что случится если одну и ту же колоду, в один и тот же момент будут менять четыре потока ?

Когда колода вернётся на место – мы не имеем ни малейшего представления, что с ней случилось в итоге. Ведь одновременно все потоки могут вытащить карту мафии, даже несмотря на то, что она одна в колоде.

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

Дело в том, что при синхронном программировании – все задачи выполняются шаг за шагом – мы взяли колоду, вытащили карту, отдали колоду следующему.

Но при многопоточном программировании – все могли взять колоду одновременно, одновременно вытащить карту и вернуть на место.

В этом одновременно и проблема и преимущество классов – одни и теже данные мы можем менять от куда угодно, когда угодно.

Если бы наша колода была структурой – то каждый из игроков взял бы полностью независимую копию колоды и мог бы менять её как угодно – это никак бы не отразилось на остальных.

Но ведь тогда теряется смысл игры) – одну колоду нужно поделить на всех, а не создать десять копий одной и той же колоды и менять её независимо.

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

На данном этапе можете не ломать голову по поводу многопоточности, поломаете позже.

Так что же выбрать Структуру или Класс ?

1. Прежде всего

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

Обычно проблему представляет ситуация, ни когда вы сталкиваетесь со системными ограничениями – тут всё просто xCode сам скажет, что вам нужен именно класс, а когда вы сталкиваетесь с логикой, которую должны продумать только вы и никто вам не подскажет.

В таком случае – всё равно выбирайте структуру (пока вы не получите достаточно опыта, что бы сразу сделать правильный выбор), но обязательно тестируйте текущее поведение и сравнивайте его с ожидаемым! (print вам в помощь)

Тест для принятия решения

Задайте себе вопрос:

“Если я дам кому-то этот объект, и он его изменит — хочу ли я, чтобы эти изменения увидели все?”

Вы работаете в фотолаборатории и собираетесь распечатать сотню копий одной фотографий – какой тип данных для фотографии выбрать?

// Фотография
struct Photo {
    var person: String
    var location: String
    var date: Date
}

let original = Photo(person: "Петя", location: "Москва", date: Date())
print(original.person) // "Петя"

// Вы делаете копию для друга и подписываете её на обратной стороне
var copyOfMyPhotoForFriend = original
copyOfMyPhotoForFriend.person = "Вася ❤️ (копия для друга)"

print(original.person) // "Петя" –   // Оригинал НЕ изменился!
print(copyOfMyPhotoForFriend.person) // "Вася ❤️ (копия для друга)"

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

Но если бы я выбрал class для Photo – то именно так и произошло бы, член на лбу пририсовался и на копии фотографии, сделанной для друга и на моей оригинальной, которую никто не трогал!

Всё зависит от конкретной ситуации, от логики, которая ожидается – возможно, если бы мы участвовали в съёмках “Гарри Поттера”, то именно так и должно быть по сценарию – Гарри дарит копию своей обнажённой фотографии Драко Малфою, тот пририсовывает нимбус на своей копии и Оооо чудо – нимбус пририсовался и на оригинальной фотографии Гарри – ММмаагия.

А теперь давайте рассмотрим другой пример –

// Наш офис
class Office {
    var buildingName: String
    var temperature: Int
    
    init(name: String, temp: Int) {
        self.buildingName = name
        self.temperature = temp
    }
}

// Мы создали наш офис
let cityOffice = Office(name: "Moscow-City", temp: 22)

// Петя и Вася получили свои места в Офисе
petya.office = cityOffice  
vasya.office = cityOffice 

// Вася изменил температуру
vasya.office.temperature = 24

// Разумеется его коллега Петя почувствует это изменение, они же в одном офисе!
// Вася же не может поменять температуру в офисе только для себя
print(petya.office.temperature) // 24

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

Класс хорош для ситуации, когда несколько объектов делят один ресурс.

2. Наследование

Структуры не поддерживают наследование!

Классы Структуры
Поддерживают наследование Не поддерживают наследование
Могут переопределять методы (override) Не могут переопределять
Могут вызывать методы суперкласса (super) Нет понятия суперкласса

Пример наследования в классе:

class Animal {
    func speak() {
        print("...")
    }
}

class Dog: Animal {
    override func speak() {
        print("Гав!")
    }
}

Со структурами такое невозможно.

3. Деинициализаторы

Классы Структуры
Могут иметь деинициализаторы deinit Не могут иметь деинициализаторы
deinit вызывается перед освобождением объекта

Пример деинициализатора:

class FileHandler {
    var filename: String
    
    init(filename: String) { 
	    self.filename = filename
    }
    
    deinit {
        print("Закрываем файл \(filename)")
    }
}

4. Изменяемость свойств в методах

Пример со структурой:

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

struct Point {
    var x = 0.0
    var y = 0.0
    
    mutating func moveBy(x deltaX: Double, y deltaY: Double) {
        x += deltaX
        y += deltaY
    }
}

let fixedPoint = Point(x: 3, y: 3)
// fixedPoint.moveBy(x: 2, y: 3) // Ошибка! Нельзя вызвать mutating метод у константы

var movablePoint = Point(x: 3, y: 3)
movablePoint.moveBy(x: 2, y: 3) // OK mutating можно вызвать только у var

В классе такого ограничения нет:

class PointClass {
    var x = 0.0
    var y = 0.0
    
    func moveBy(x deltaX: Double, y deltaY: Double) {
        self.x += deltaX
        self.y += deltaY
    }
}

let point = PointClass()
point.moveBy(x: 2, y: 3) // Работает, даже если экземпляр константа

Мы не должны писать mutating func мы спокойно можем вызывать функцию у let (константы)

5. Сравнение на идентичность

Классы Структуры
Можно сравнивать ссылки с помощью операторов === и !== (равны ли ссылки на один объект) Для структур такого оператора нет; обычно сравнивают по свойствам через ==, если структура соответствует Equatable

Пример сравнения классов:

Благодаря тернарному знаку равно (===) мы можем проверить, что данные в классе не просто одинаковы, а то, что они имеют одну и ту же ссылку и следовательно ссылаются на одно и тоже место в памяти.

let carA = Car(model: "Tesla")
let carB = carA
let carC = Car(model: "Tesla")

print(carA === carB) // true (одна и та же ссылка)
print(carA === carC) // false (разные объекты), хотя модели абсолютно равны (Tesla == Tesla)

Структуры не поддерживают ===, так как у них нет ссылок.

Таблица сравнения классов и структур

Для удобства сведём все отличия в одну таблицу.

Характеристика Класс Структура
Тип Ссылочный Значимый
Хранение Куча (heap) Стек (stack)
Присваивание Копируется ссылка Копируется значение
Наследование Поддерживается Не поддерживается
Деинициализатор Присутствует (deinit) Отсутствует
Mutating методы Не нужны Требуют mutating
Идентичность ===, !== Нет
Циклические ссылки Возможны Нет
Использование в коллекциях Хранятся ссылки Хранятся копии
Производительность Накладные расходы на кучу и ARC Эффективнее (стек)

Разницу между классом и структурой – нужно чувствовать сердцем, а это приходит только с опытом.

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

Заключение

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

Понимание этих отличий помогает писать безопасный, эффективный и понятный код.