Предисловие

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

Изначально я планировал изложить все в формате одной публикации, но информации так много, что я решил разбить материал на части.

Весь свой информационный мусор, я буду коллекционировать на своей стене в ВК, так что добро пожаловать.

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

Вы должны понимать синтаксис языка, иметь общее представление о разработке и ОПП, хотя бы отдаленное. В остальном, каких-либо требований и знаний не требуется.

Разбираемся с классами

Обычно, в учебных пособиях, книгах и прочих источниках информации, class'ы объясняют примерно так «class это - описание объекта, а объект это экземпляр класса и бла бла бла», в принципе, это частично отражает суть конструкции, но называть class в рамках языка Swift, описанием объекта, будет не совсем корректно т.к. он же еще и представляет собой тип данных и вообще можно использовать классы как независимые, самодостаточные сущности, которые не требуют инициализации. Поэтому начинать со слов «Возьмем объект животного, пускай это будет кот...» я конечно же не буду. И вообще, давайте не будем о "сложном" т.к. материал рассчитан все же на новичков, а новички могут и не знать, что такое, эти ваши объекты и инициализации. В общем я считаю подобное (я про формулировку) не достаточно информативным, поэтому будем разбирать все на примерах с переходом от простого к более сложному. Попутно к ознакомлению с классами, мы будем так же рассматривать и другие возможности языка, но обо всем по порядку.

Итак, начнем, сейчас мы создадим простой фрагмент кода

let factor: Int = 5

func multiplication(_ cell: Int) -> Int {
    return cell * factor
}

let result = multiplication(5)

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

Мы создаем константу factor (множитель) и функцию multiplication(умножение), константа у нас относится к типу Int, функция принимает Int, производит вычисления и возвращает результат, тоже в Int. Этот результат, мы принимаем в константу result, которая НЕЯВНО объявляется как Int т.к. принимает Int, а функция имеет доступ

к константе factor т.к. она попадает в область видимости функции, потому как находится на одном уровне с ней (запоминаем, дальше вернемся к этому).

Так же, всё, что Вы видите, имеет название по назначению, иными словами, Вы больше не будете смеяться над мемами из пабликов с iT юмором т.к. существует простое правило присваивания названий и вы сейчас с ним столкнулись (надеюсь не в первый раз). Что видим, то и пишем. Что делает, так и называем и т.д. Дальше мы тоже об этом поговорим. Код всегда должен быть понятным и информативным. Но собственно продолжим.

Ожидаемо, что результатом функции будет 25

Но давайте представим, что по каким-либо причинам, нам нужно использовать эту функцию в других частях программы и с разными множителями. Приходит, что либо на ум? Я покажу два простых и ожидаемых варианта подобной реализации (опустим возможность рефакторинга этого убожества). Очевидный вариант, передавать аргумент содержащий множитель в функцию, что освобождает нас от внешней

let factor: Int = 5

func multiplication(_ cell: Int, _ factor: Int) -> Int {
    return cell * factor
}

let result = multiplication(5, factor)

зависимости и менее очевидный и более извращенный способ - сделать factor переменной, и изменять ее перед вызовом.

var factor: Int = 5

func multiplication(_ cell: Int) -> Int {
    return cell * factor
}

factor = 7
let result = multiplication(5)

print(result)

Соответственно изменяя зависимость перед вызовом, мы достигаем нужного результата и множитель нам подконтролен.

В нашем странном проекте, требуется, чтобы значение множителя по умолчанию было равно 5, и если в варианте с передачей множителя аргументом это реализуется вот так,

func multiplication(_ cell: Int, _ factor: Int = 5) -> Int {
    return cell * factor
}

с внешней зависимостью это будет выглядеть иначе

func multiplication(_ cell: Int) -> Int {
    let currentFactor = factor
    factor = 5
    return cell * currentFactor
}

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

func multiplication(_ cell: Int, _ factor: inout Int) -> Int {
    let currentFactor = factor
    factor = 5
    return cell * currentFactor
}

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

Есть у нас например интернет-магазин и продаем мы ножи. Продаем мы их как в России так и в Америке, за доллары и за рубли соответственно. Покупаем за евро т.к. сами мы ничего не производим, а просто закупаем в виде полосок стали, чтобы стоимость зависела от длины клинка (удобно для примера), чем длиннее нож, тем он дороже. И конечно коэффициента, который зависит от сложности выполнения работ. Давайте все это оформим в коде.

//... получаем с сервера данные
let length: Double = 14 //Длина
let name: String = "myFirstKnife" //Имя
let purchaseСost: Double = 10 //Закупка стали
let coefficient: Double = 1.2 //Коифицент

Теперь эти данные нам необходимо как-либо обработать, чтобы получить стоимость для разных стран. Стоимость конечно же рассчитываем исходя из длины и коэффициента. Наценка у нас будет 40%, закупочная стоимость идет за метр стали, длина сохраняется в сантиметрах. Математика получается достаточно простой. Реализацию подсчетов так же сделаем весьма грубой (вместо типа данных для финансов, будем использовать double) и начнем с получения стоимости в рублях

//... получаем с сервера данные
let length: Double = 14 //Длина
let name: String = "myFirstKnife" //Имя
let purchaseСost: Double = 10 //Закупка стали
let coefficient: Double = 1.2 //Коифицент

let dollarRate: Double = 70 //руб. за доллар
let euroRate: Double = 80 //руб. за евро
let markupOnGoods: Double = 0.4 //наценка на ножи

func getPriceRuble() -> Double {
    let purchaseСostRuble = purchaseСost * euroRate
    let totalPurchase = (purchaseСostRuble / 100) * length * coefficient
    return totalPurchase + totalPurchase * markupOnGoods
}

let rub = getPriceRuble()

Добавим функцию для получения прайса в долларах

func getPriceRubleDollar() -> Double {
    let purchaseСostRuble = purchaseСost * euroRate
    let totalPurchase = (purchaseСostRuble / 100) * length * coefficient
    return (totalPurchase + totalPurchase * markupOnGoods) / dollarRate
}

Итого у нас есть работающий код, убогий, но выполняющий свою функцию. Какие проблемы есть у этого кода на текущий момент? Внедрение внешних зависимостей в функцию, но от этого легко избавится

func getPriceRuble(
    _ length: Double,
    _ purchaseСost: Double,
    _ coefficient: Double,
    _ euroRate: Double,
    _ markupOnGoods: Double) -> Double {
    let purchaseСostRuble = purchaseСost * euroRate
    let totalPurchase = (purchaseСostRuble / 100) * length * coefficient
    return totalPurchase + totalPurchase * markupOnGoods
}

func getPriceRubleDollar(
    _ length: Double,
    _ purchaseСost: Double,
    _ coefficient: Double,
    _ euroRate: Double,
    _ markupOnGoods: Double,
    _ dollarRate: Double) -> Double {
    let purchaseСostRuble = purchaseСost * euroRate
    let totalPurchase = (purchaseСostRuble / 100) * length * coefficient
    return (totalPurchase + totalPurchase * markupOnGoods) / dollarRate
}

let rub = getPriceRuble(length, purchaseСost, coefficient, euroRate, markupOnGoods)
let dollar = getPriceRubleDollar(length, purchaseСost, coefficient, euroRate, markupOnGoods, dollarRate)

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

enum Currency {
    case ruble
    case dollar
}

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

func getPrice(
    _ type: Currency,
    _ length: Double,
    _ purchaseСost: Double,
    _ coefficient: Double,
    _ euroRate: Double,
    _ markupOnGoods: Double,
    _ dollarRate: Double) -> Double {
        let purchaseСostRuble = purchaseСost * euroRate
        let totalPurchase = (purchaseСostRuble / 100) * length * coefficient
        
        switch type
        {
            case .dollar:
                return (totalPurchase + totalPurchase * markupOnGoods) / dollarRate
            case .ruble:
                return totalPurchase + totalPurchase * markupOnGoods
        }
        
    }

Код работает, но он громоздкий и им неудобно пользоваться. Что нам делать, если мы получаем например два ножа? Как именовать переменные? name2, coefficient2? Конечно же нет. Нам помогут объекты. И сейчас мы опишем объект нашего ножа классом и познакомимся с инициализатором.

Перенесем функцию в класс, которая станет его методом.

Перенесем перечисление в класс, которое тоже станет перечислением класса.

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

class Knife {
    
    //Свойства класса
    let length: Double
    let name: String
    let coefficient: Double
    
    //Инициализатор класса
    init
    (
        _ name: String,
        _ length: Double,
        _ coefficient: Double
    )
    {
        self.name = name
        self.length = length
        self.coefficient = coefficient
    }
    
    //Метод класса
    func getPrice(_ type: Currency) -> Double {
            let purchaseСostRuble = purchaseСost * euroRate
            let totalPurchase = (purchaseСostRuble / 100) * length * coefficient
            
            switch type
            {
            case .dollar:
                return (totalPurchase + totalPurchase * markupOnGoods) / dollarRate
            case .ruble:
                return totalPurchase + totalPurchase * markupOnGoods
            }
            
        }
    
    enum Currency {
        case ruble
        case dollar
    }
    
}

Когда мы описываем класс, мы описываем его свойства и методы. Если описываемые свойства не имеют значений, они должны быть определены в инициализаторе.

Инициализатор получает значения как функция получает аргументы и формирует экземпляр объекта. Поэтому мы можем получать данные о ножах непосредственно в экземпляры.

//... получаем с сервера данные
let firstKnife = Knife("firstKnife", 14, 1.2)
let secondKnife = Knife("secondKnife", 16, 1.3)

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

Как вы можете заметить, это куда более удобный вариант работы с данными, так как каждый экземпляр обладает определенными методами и работает со своими внутренними данными, которые так же можно смешивать с глобальными. Но давайте реализуем в нашем текущем коде нововведение, представим, что нам завезли ножи у которых есть чехлы и прайс зависит еще и от стоимости чехла. Нам, что новый класс описывать? Да нет. Мы создадим новый класс, который унаследует методы и свойства нашего первого класса.

class KnifeWithSheath : Knife {
    
    let priceSheath: Double
    
    init(_ name: String, _ length: Double, _ coefficient: Double, _ priceSheath: Double) {
        self.priceSheath = priceSheath
        super.init(name, length, coefficient)
    }
    
    override func getPrice(_ type: Knife.Currency) -> Double {
        let superResult = super.getPrice(type)
        return superResult + self.priceSheath
    }
}

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

let firstKnifeWithSheath = KnifeWithSheath("firstKnife", 14, 1.2, 1000)

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

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

Рассмотрим вариации использования классов. Начнем конечно же с создания класса.

class MyClass {
    
    var myProperty = 0
    
    func myMethod(){
        self.myProperty += 1
    }
}

В нашем классе есть свойство и метод. Как же мы можем взаимодействовать с классом? Вариант первый - создать его экземпляр.

let myClass = MyClass()

Теперь мы можем обратится к свойству либо методу через него

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

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

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

class MyClass {
    
    static let shared = MyClass()
    var myProperty = 0
    
    func myMethod(){
        self.myProperty += 1
    }
}

К слову, в подсказке Xcode, вы вероятно заметили init(), это как раз таки метод инициализации класса, если можно так выразится, с которым мы знакомились ранее и Swift позволяет опускать его до круглых скобок. Ну и как мы говорили ранее, инициализатор не требуется, если класс самодостаточен (определена структура, свойства и т.д.), но это не означает, что мы не можем им воспользоваться. Можно например вызвать в нем свой собственный метод или выполнять вычисления. И конечно инициализаторов может быть несколько.

class MyClass {
    
    static let shared = MyClass()
    var myProperty = 1
    var mySecondProperty: Int
    
    init(_ property: Int){
        self.mySecondProperty = myProperty * 2
        self.myMethod()
    }
    init(){
        self.mySecondProperty = 0
        self.myMethod()
    }
    
    func myMethod(){
        self.myProperty += 1
    }
}

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

class MyClass {
    
    static let shared = MyClass()
    static var myProperty : Int = 1
    static var mySecondProperty: Int = 1
    
    class func myMethod(){
        self.myProperty += 1
    }
}

Соответственно значения хранятся непосредственно в типе и не требуют экземпляра, с ними можно работать, они сохраняют свое состояние.

Почему я называю его типом? (вообще правильнее вероятно называть классом и объектом, но это может привести к путанице, поэтому тип и класс) Сейчас поймете, давайте познакомимся с еще одной удобной возможностью которая относится как к экземплярам так и типам класса.


class MyClass {
    
    static let shared = MyClass()
    
    static var myProperty : Int = 1
    var myProperty : Int = 1
    
    class func myMethod() -> MyClass.Type {
        self.myProperty += 1
        return self
    }
    func myMethod() -> MyClass {
        self.myProperty += 1
        return self
    }
}

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

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

Наследование классов

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

class Sum {
 
    var firstValue: Int
    var secondValue: Int
    
    init(toFirstValue first: Int, toSecondValue second: Int){
        firstValue = first
        secondValue = second
    }
    
    func sum() -> Int { firstValue + secondValue }
}

Обратите внимание

  • мы не используем self в инициализаторе т.к. имена параметров, передаваемых в инициализатор, отличаются от свойств класса, соответственно Swift позволяет его опускать.

  • мы не используем return в методе sum, т.к. Swift позволяет его опускать при использовании однострочных вычислений

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

    var calc: Int {
        get {
            return firstValue + secondValue
        }
    }

Т.к мы используем только вычисления (есть и другие возможности, но об этом позже), мы можем опустить конструкцию get{}

    var calc: Int {
        return firstValue + secondValue
    }

и конечно так же опустим return

var calc: Int { firstValue + secondValue }

итоговый вариант будет выглядеть так

class Sum {
    
    var firstValue: Int
    var secondValue: Int
    
    var calc: Int { firstValue + secondValue }
    
    init(toFirstValue first: Int, toSecondValue second: Int){
        firstValue = first
        secondValue = second
    }
    
    func sum() -> Int { firstValue + secondValue }
}

Выведем результат

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

Собственно наш первый класс готов и давайте создадим еще один, но уже для умножения значений, унаследовав его от класса для сложения

class Multiplier: Sum {
    
    var calcMulti: Int { firstValue * secondValue }
}

И еще один класс, который будет унаследован уже от класса умножения

class Other: Multiplier {}

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

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

class NewClass {
    
    var number: Int = 0 {
        didSet {
            self.method(self.number)
        }
    }
    
    func method(_ number: Int) {
        print(number)
    }
    
    func numberUp(){
        self.number += 1
    }
}

Конструкция didSet означает, что в момент изменения свойства number, будет выполнен блок кода внутри {}

уже с полученным новым значением. Теперь создадим класс наследующий NewClass

class childClass: NewClass {
    
    override func method(_ number: Int) {
        print("переопределен")
        print("новое значение number - \(number)")
    }
}

override позволяет переопределять методы, пример использования вы могли наблюдать неоднократно, например в viewDidLoad. Когда вы проектируете класс, он может выполнять множество различных действий, многие из которых необходимо наблюдать и выполнять различные действия после их завершения, от этого класса можно получать наследование и при необходимости использовать оставленные методы. Т.е. метод method() класса NewClass не обязательно должен, что-либо выполнять, его можно оставлять пустым, для последующего использования.

Так же, желательно передавать параметры, в подобные методы, даже если они находятся в области видимости, что-бы в последующем, при переопределении, было понятно, что в него приходит (какие данные получает).

Доступы и ограничения

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

class NewClass {
    
    var number: Int = 0 {
        didSet {
            self.buferMethod()
        }
    }
    
    func buferMethod(){
        self.method(self.number)
    }
    
    func method(_ number: Int) {
        print("number изменен")
    }
    
    func numberUp(){
        self.number += 1
    }
}

Теперь, при изменении свойства number - выполняется метод buferMethod, который в свою очередь вызывает method() информирующий об изменении number т.к. нам может понадобиться выполнить ряд других операций в момент изменения и вызываемый метод в наблюдателе не будет является конечной точкой.

Далее переопределим buferMethod в классе childClass и изменим его название на СhildClass т.к. типы и классы принято начинать с верхнего регистра.

class СhildClass: NewClass {
    
    override func buferMethod() {
        print("buferMethod переопределен")
    }
    
    override func method(_ number: Int) {
        print("новое значение number - \(number)")
    }
}

и посмотрим, что произойдет

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

Перед методом необходимо указать запрет на переопределение final

    final func buferMethod(){
        print("buferMethod выполняется")
        self.method(self.number)
    }

в таком случае, метод переопределить не удастся и Xcode соответственно, выведет ошибку с пояснениями

final так же можно использовать для запрета наследования, обозначив им класс

final class NewClass {}

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

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

class MyClass {
    
    var intCell: Int = 0
    
    var int: Int { self.intCell }
    
    func setInt(newValue: Int){
        if checkInt(value: newValue) {
            self.intCell = newValue
        }
    }

    func checkInt(value: Int) -> Bool{
        if value > 0 { return true }
        else { return false }
    }
}

Мы создали класс, который выполняет простую задачу, дает нам доступ к числовому значению int и позволяет его изменять строго на положительное т.к. изменения происходят через проверяющий метод. Но вся наша логика безполезна в подобной реализации т.к. мы без проблем установим свойство напрямую в intCell

т.к. разрабатывая класс, мы ожидаем использование метода setInt для модификации свойства intCell. Это первая проблема, проблема с безопасностью, о которой я говорил. Так же она проявляет себя в возможности вызова методов, которые не должный быть выполнены самостоятельно, что видно на примере метода checkInt , который не должен быть использован нигде, кроме как внутри метода setInt. Если бы метод checkInt изменял состояние класса, то его вызов так же мог бы привести к ошибкам. Помимо безопасности, Xcode будет предлагать ненужные методы и свойства, что является второй проблемой, неудобством использования класса.

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

class MyClass {
    
    private var intCell: Int = 0
    
    public var int: Int { self.intCell }
    
    public func setInt(newValue: Int){
        if checkInt(value: newValue) {
            self.intCell = newValue
        }
    }

    private func checkInt(value: Int) -> Bool{
        if value > 0 { return true }
        else { return false }
    }
}

теперь мы не имеем доступа к методам и свойствам private

а если попробуем обратится напрямую - получим ошибку.

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

Продолжение будет во второй части.

See you later....

Комментарии (2)


  1. On1xS
    02.12.2021 21:56

    Получается есть 2 способа создания метода класса

    class func myMethod()
    static func myMethod()
    

    В чём между ними разница, если она есть?


    1. cbepxbeo Автор
      02.12.2021 21:57

      Об этом будет во второй части, когда будет о структурах.