Link Search Menu Expand Document

Непрозорі типи

Функція чи метод із непрозорим типом, що повертається, приховують інформацію про тип значення, що вони повертають. Замість того, щоб вказати конкретний тип, котрий повертає функція, значення, що повертається, описується у термінах протоколів, до котрих воно підпорядковане. Приховування інформації про тип буває корисним на межі між модулем та кодом, що робить виклики методів чи функцій з цього модуля, оскільки інформація про фактичний тип значення, що повертається, залишається приватною. На відміну від повернення значення, чий тип є протоколом, непрозорі типи зберігають повну інформацію про тип: компілятор має доступ до інформації про тип, однак клієнти цього модуля – ні.

Проблема, яку вирішують непрозорі типи

Наприклад, припустимо, що ми пишемо модуль, котрий малює фігури з ASCII-графіки. Базовою характеристикою будь-якої фігури є функція draw(), котра повертає представлення фігури у вигляді рядка. Цей метод можна використати як вимогу до протоколу Shape:

protocol Shape {
    func draw() -> String
}

struct Triangle: Shape {
    var size: Int

    func draw() -> String {
        var result = [String]()
        for length in 1...size {
            result.append(String(repeating: "*", count: length))
        }
        return result.joined(separator: "\n")
    }
}
let smallTriangle = Triangle(size: 3)
print(smallTriangle.draw())
// *
// **
// ***

Для реалізації операцій на кшталт перевороту фігури по вертикалі, можна скористатись узагальненнями, як показано у коді нижче. Однак, даний підхід має певні обмеження: перевернутий результат виставляє напоказ точний узагальнений тип, що було використано для створення перевернутої фігури.

struct FlippedShape<T: Shape>: Shape {
    var shape: T
    func draw() -> String {
        let lines = shape.draw().split(separator: "\n")
        return lines.reversed().joined(separator: "\n")
    }
}
let flippedTriangle = FlippedShape(shape: smallTriangle)
print(flippedTriangle.draw())
// ***
// **
// *

Цей же підхід можна застосувати для визначення структури JoinedShape<T: Shape, U: Shape>, за допомогою якої можна поєднувати дві фігури вертикально. Однак, якщо, наприклад, потрібно поєднати перевернутий трикутник з іншим трикутником, результат матиме тип на кшталт JoinedShape<FlippedShape<Triangle>, Triangle>.

struct JoinedShape<T: Shape, U: Shape>: Shape {
    var top: T
    var bottom: U
    func draw() -> String {
        return top.draw() + "\n" + bottom.draw()
    }
}
let joinedTriangles = JoinedShape(top: smallTriangle, bottom: flippedTriangle)
print(joinedTriangles.draw())
// *
// **
// ***
// ***
// **
// *

Виставлена на-гора детальна інформація про створення фігури дозволяє типам, котрі не призначені бути частиною публічного інтерфейсу модуля ASCII-графіки, витікати назовні, оскільки потрібно зазначати повністю тип, що повертається. Код всередині модуля може створити одну й ту ж фігуру у кілька різних способів, а код зовні модуля, що використовує фігури, не повинен брати до уваги деталі реалізації, зокрема інформацію про список перетворень фігур. Обгортки на кшталт JoinedShape та FlippedShape не мають значення для користувача модулі, і тому вони не повинні бути видимими. Публічний інтерфейс модуля повинен складатися з операцій, на кшталт об’єднання та перевертання фігури, і ці операції повинні повертати інше значення Shape.

Повернення непрозорих типів

Непрозорі типи можна уявляти узагальненими типами навпаки. Узагальнені типи дозволяють коду, що викликає функцію, обирати тип параметрів функції та тип, що нею повертається, в абстрагований від реалізації функції спосіб. Наприклад, функція у наступному коді повертає тип, що залужить від коду, що викликає дану функцію:

func max<T>(_ x: T, _ y: T) -> T where T: Comparable { ... }

Код, що викликає функцію max(_:_:) обирає значення x та y, і тип цих значень визначає конкретний тип T. Код, що викликає цю функцію, може використовувати будь-який підпорядкований протоколу Comparable тип. Код усередині функції записаний в узагальнений спосіб, тому він може опрацювати будь-який наданий тип. Реалізація функції max(_:_:) використовує лише ту функціональність, котру мають усі підпорядковані протоколу Comparable типи.

Ці ролі міняються місцями для функції, що повертає непрозорий тип. Непрозорий тип дозволяє реалізації функції обирати тип значення, що вона повертає, у спосіб, абстрагований від коду, що викликає дану функцію. Наприклад, у наступному фрагменті коду функція повертає трапецію, без викривання фактичного типу даної фігури.

struct Square: Shape {
    var size: Int
    func draw() -> String {
        let line = String(repeating: "*", count: size)
        let result = Array<String>(repeating: line, count: size)
        return result.joined(separator: "\n")
    }
}

func makeTrapezoid() -> some Shape {
    let top = Triangle(size: 2)
    let middle = Square(size: 2)
    let bottom = FlippedShape(shape: top)
    let trapezoid = JoinedShape(
        top: top,
        bottom: JoinedShape(top: middle, bottom: bottom)
    )
    return trapezoid
}
let trapezoid = makeTrapezoid()
print(trapezoid.draw())
// *
// **
// **
// **
// **
// *

Функція makeTrapezoid() у цьому прикладі оголошує тип, що повертається, як some Shape. Як результат, функція повертає значення якогось заданого типу, що підпорядковується до протоколу Shape, не вказуючи будь-якого конкретного типу. Оголошення функції makeTrapezoid() у такий спосіб виражає фундаментальний аспект її публічного інтерфейсу – значення, що повертається, є фігурою – без зазначення фактичних типів з яких складається фігура у публічному інтерфейсі. Дана реалізація використовує два трикутники та квадрат, однак цю функцію можна переписати, генеруючи трапецію одним із багатьох різних способів, не змінюючи тип, що повертається.

У даному прикладі підкреслюється той факт, що непрозорий тип, що повертається, є подібним до узагальненого типу навпаки. Код всередині makeTrapezoid() може повертати будь-який тип, який потрібно, аби лише цей тип був підпорядкований протоколу Shape, аналогічно до коду, що викликає узагальнену функцію. Код, що викликає функцію makeTrapezoid(), повинен бути написаним в узагальненій манері, аналогічно до реалізації узагальненої функції, так, щоб він міг працювати із будь-яким значенням Shape, що повертається функцією makeTrapezoid().

Можна також комбінувати непрозорі типи, що повертаються, з узагальненнями. Обидві функції у коді нижче повертають значення якогось типу, що підпорядковується протоколу Shape:

func flip<T: Shape>(_ shape: T) -> some Shape {
    return FlippedShape(shape: shape)
}
func join<T: Shape, U: Shape>(_ top: T, _ bottom: U) -> some Shape {
    JoinedShape(top: top, bottom: bottom)
}

let opaqueJoinedTriangles = join(smallTriangle, flip(smallTriangle))
print(opaqueJoinedTriangles.draw())
// *
// **
// ***
// ***
// **
// *

Значення opaqueJoinedTriangles у цьому прикладі є таким же, як і joinedTriangles у прикладі з узагальненнями у підрозділі Проблема, яку вирішують непрозорі типи вище. Однак, на відміну від значення у тому прикладі, функції flip(_:) та join(_:_:) обгортають тип за лаштунками таким чином, що узагальнені операції з фігурами стають прихованими за непрозорим типом, що запобігає видимості цих операцій. Обидві функції є узагальненими, оскільки типи, на які вони покладаються, є узагальненими, а параметри типів, що передаються до цих функції, передають інформацію про тип, потрібну для структур FlippedShape та JoinedShape.

У функції, котра повертає непрозорий тип із декількох різних місць, усі можливі значення, що повертаються, повинні мати однаковий тип. В тому числі й для узагальненої функції, у якої тип, що повертається, може визначатись одним із параметрів типу, це має бути одним типом. Наприклад, ось некоректна версія функції, котра перевертає фігуру, та обробляє квадрати окремим чином:

func invalidFlip<T: Shape>(_ shape: T) -> some Shape {
    if shape is Square {
        return shape // Помилка: типи, що повертаються, не співпадають
    }
    return FlippedShape(shape: shape) // Помилка: типи, що повертаються, не співпадають
}

Якщо викликати функцію з аргументом типу Square, вона повертає Square, в інших випадках вона повертає FlippedShape. Це суперечить вимозі співпадіння типів, що повертаються, та робить реалізацію invalidFlip(_:) некоректним кодом. Одним зі способів виправити функцію invalidFlip(_:) є перенесення спеціального випадку для квадратів до реалізації структури FlippedShape, що дозволяє функції завжди повертати значення типу FlippedShape:

struct FlippedShape<T: Shape>: Shape {
    var shape: T
    func draw() -> String {
        if shape is Square {
            return shape.draw()
        }
        let lines = shape.draw().split(separator: "\n")
        return lines.reversed().joined(separator: "\n")
    }
}

Вимога завжди повертати значення одного типу не заважає використовувати узагальнення у не прозорих типах, що повертаються. Ось приклад функції, що вбудовує параметр типу у внутрішній тип значення, що повертається:

func `repeat`<T: Shape>(shape: T, count: Int) -> some Collection {
    return Array<T>(repeating: shape, count: count)
}

В даному прикладі, тип значення, що повертається, залежить від T: яка б фігура не передавалась, функція repeat(shape:count:) створює та повертає масив типу цієї фігури. Разом з тим, значення, що повертається, завжди має один і той же тип [T], тому дана функція відповідає вимозі, що функції що повертають непрозорий тип, повинні повертати значення одного типу.

Різниця між непрозорими типами та протоколами

Повернення непрозорого типу є дуже схожим на використання протоколу як типу, що повертається функцією, але ці два різновиди типів, що повертаються, відрізняються у тому, чи зберігають вони ідентичність типу. Непрозорий тип посилається на один конкретний тип, хоч той, хто викликає функцію, не може бачити, на який саме. Тип-протокол може посилатись на будь-який підпорядкований йому тип. Взагалі, протоколи дають більше гнучкості щодо фактичних типів значень, що вони зберігають, а непрозорі типи дозволяють давати сильніші гарантії щодо цих фактичних типів.

Наприклад, ось версія функції flip(_:), що повертає значення типу протоколу, замість використання непрозорого типу:

func protoFlip<T: Shape>(_ shape: T) -> Shape {
    return FlippedShape(shape: shape)
}

Ця версія protoFlip(_:) має таке ж тіло, що й flip(_:), і вона завжди повертає значення одного й того ж типу. На відміну від функції flip(_:), значення, котре повертає функція protoFlip(_:) не повинно завжди мати однаковий тип – достатньо лише, щоб воно підпорядковувалось до протоколу Shape. Іншими словами, функція protoFlip(_:) укладає ширший API-контракт з кодом, що її викликає, аніж функція flip(_:). Вона залишає за собою гнучкість повертати значення кількох різних типів:

func protoFlip<T: Shape>(_ shape: T) -> Shape {
    if shape is Square {
        return shape
    }

    return FlippedShape(shape: shape)
}

Ця уточнена версія функції повертає або екземпляр Square, або екземпляр FlippedShape, в залежності від того, яку фігуру в неї передали. Дві фігури, що повертаються даною функцією, можуть мати зовсім різні типи. Інші коректні версії цієї функції можуть повертати значення різних типів при повороті різних екземплярів фігури одного типу. Менш конкретна інформація про тип, що повертається функцією protoFlip(_:), означає, що багато залежних від інформації про тип операцій на значенні, що повертається, є недоступними. Наприклад, неможливо написати оператор ==, що порівнював би між собою результати викликів цієї функції.

let protoFlippedTriangle = protoFlip(smallTriangle)
let sameThing = protoFlip(smallTriangle)
protoFlippedTriangle == sameThing  // Помилка

Помилка в останньому рядку даного прикладу трапляється з кількох причин. Першочергова проблема у тому, що протокол Shape не включає оператор == до списку своїх вимог. Якщо спробувати додати його, наступною проблемою стане те, що оператору == потрібно буде знати типи свого лівого та правого аргументу. Цей різновид оператору зазвичай приймає аргументи типу Self, що співпадають із будь-яким конкретним підпорядкованим до цього протоколу типом. Але додавання вимог з посиланням на Self до протоколу запобігає стиранню інформації про тип, що відбувається при використанні протоколу як типу.

Використання протоколу як типу, що повертається функцією, дає гнучкість повертати будь-який підпорядкований протоколу тип. Однак, платою за цю гнучкість є те, що деякі операції над значеннями, що повертаються, стають недоступними. Зокрема, приклад демонструє недоступність оператору ==: він залежить від інформації про конкретний тип, що не зберігається при використанні протоколу як типу.

Іншою проблемою з даним підходом є те, що перетворення фігур не можна вкладати одне в одне. Результатом перевертання трикутника є значення типу Shape, а функція protoFlip(_:) приймає аргумент якогось підпорядкованого до протоколу Shape типу. Однак, значення типу-протоколу не підпорядковується до цього протоколу; значення, що повертається функцією protoFlip(_:), не підпорядковується до протоколу Shape. Це означає, що код на кшталт protoFlip(protoFlip(smallTriange)), що застосовує декілька трансформацій, є некоректним: перевернута фігура не є коректним аргументом для функції protoFlip(_:).

На відміну від протоколів, непрозорі типи зберігають ідентичність фактичного типу. Swift може визначити асоційовані типи, що дозволяє повертати непрозорі типи у тих місцях, де неможливо повертати протоколи. Наприклад, ось версія протоколу Container із розділу Узагальнення:

protocol Container {
    associatedtype Item
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}
extension Array: Container { }

Протокол Container не можна використовувати як тип, що повертає функція, оскільки цей протокол має асоційований тип. Його також не можна використовувати як обмеження на параметр типу, що повертається функцією, оскільки за межами тіла функції недостатньо інформації, щоб визначити, яким повинен бути узагальнений тип.

// Помилка: протокол із асоційованим типом не можна використовувати як тип значення, що повертається.
func makeProtocolContainer<T>(item: T) -> Container {
    return [item]
}

// Помилка: недостатньо інформації для визначення типу C.
func makeProtocolContainer<T, C: Container>(item: T) -> C {
    return [item]
}

Використання непрозорого типу some Container як типу, що повертається, виражає бажаний API-контракт: функція повертає контейнер, але відмовляється визначити тип контейнера:

func makeOpaqueContainer<T>(item: T) -> some Container {
    return [item]
}
let opaqueContainer = makeOpaqueContainer(item: 12)
let twelve = opaqueContainer[0]
print(type(of: twelve))
// Надрукує "Int"

Тип змінної twelve визначено як Int, що ілюструє факт, що визначення типів працює з непрозорими типами. У реалізації функції makeOpaqueContainer(item:), фактичним типом непрозорого контейнера є [T]. У цьому випадку, T є типом Int, тому значення, що повертається, є масивом цілих, а асоційований тип Item визначено як Int. Індекс протоколу Container повертає значення типу Item, що означає, що тип змінної twelve також визначено як Int.