Jak podejść do opakowań w celu uzyskania właściwości Swift

Opublikowany: 2022-03-11

Mówiąc prościej, opakowanie właściwości to ogólna struktura, która hermetyzuje dostęp do odczytu i zapisu do właściwości i dodaje do niej dodatkowe zachowanie. Używamy go, jeśli potrzebujemy ograniczyć dostępne wartości właściwości, dodać dodatkową logikę dostępu do odczytu/zapisu (np. przy użyciu baz danych lub wartości domyślnych użytkownika) lub dodać kilka dodatkowych metod.

Opakowania nieruchomości w Swift 5.1

Ten artykuł dotyczy nowego podejścia Swift 5.1 do zawijania właściwości, które wprowadza nową, czystszą składnię.

Stare podejście

Wyobraź sobie, że tworzysz aplikację i masz obiekt zawierający dane profilu użytkownika.

 struct Account { var firstName: String var lastName: String var email: String? } let account = Account(firstName: "Test", lastName: "Test", email: "[email protected]") account.email = "[email protected]" print(account.email)

Chcesz dodać weryfikację adresu e-mail — jeśli adres e-mail użytkownika jest nieprawidłowy, właściwość email musi wynosić nil . Byłby to dobry przypadek, aby użyć otoki właściwości do hermetyzacji tej logiki.

 struct Email<Value: StringProtocol> { private var _value: Value? init(initialValue value: Value?) { _value = value } var value: Value? { get { return validate(email: _value) ? _value : nil } set { _value = newValue } } private func validate(email: Value?) -> Bool { guard let email = email else { return false } let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-za-z]{2,64}" let pred = NSPredicate(format: "SELF MATCHES %@", regex) return pred.evaluate(with: email) } }

Możemy użyć tego wrappera w strukturze Konta:

 struct Account { var firstName: String var lastName: String var email: Email<String> }

Teraz jesteśmy pewni, że właściwość e-mail może zawierać tylko prawidłowy adres e-mail.

Wszystko wygląda dobrze, z wyjątkiem składni.

 let account = Account(firstName: "Test", lastName: "Test", email: Email(initialValue: "[email protected]")) account.email.value = "[email protected]" print(account.email.value)

Dzięki opakowaniu właściwości składnia inicjowania, odczytywania i zapisywania takich właściwości staje się bardziej złożona. Czy można więc uniknąć tej komplikacji i używać opakowań właściwości bez zmiany składni? W przypadku Swift 5.1 odpowiedź brzmi: tak.

Nowy sposób: @propertyWrapper Adnotacja

Swift 5.1 zapewnia bardziej eleganckie rozwiązanie do tworzenia opakowań właściwości, gdzie dozwolone jest oznaczanie opakowania właściwości adnotacją @propertyWrapper . Takie wrappery mają bardziej zwartą składnię w porównaniu z tradycyjnymi, co skutkuje bardziej zwartym i zrozumiałym kodem. Adnotacja @propertyWrapper ma tylko jedno wymaganie: Twój obiekt opakowujący musi zawierać niestatyczną właściwość o nazwie wrappedValue .

 @propertyWrapper struct Email<Value: StringProtocol> { var value: Value? var wrappedValue: Value? { get { return validate(email: value) ? value : nil } set { value = newValue } } private func validate(email: Value?) -> Bool { guard let email = email else { return false } let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailPred.evaluate(with: email) } }

Aby zdefiniować tak opakowaną właściwość w kodzie, musimy użyć nowej składni.

 @Email var email: String?

Tak więc oznaczyliśmy właściwość adnotacją @ . Typ właściwości musi być zgodny z typem opakowania „wrappedValue”. Teraz możesz pracować z tą właściwością jak ze zwykłą.

 email = "[email protected]" print(email) // [email protected] email = "invalid" print(email) // nil

Świetnie, teraz wygląda lepiej niż przy starym podejściu. Ale nasza implementacja opakowująca ma jedną wadę: nie zezwala na początkową wartość opakowanej wartości.

 @Email var email: String? = "[email protected]" //compilation error.

Aby rozwiązać ten problem, musimy dodać następujący inicjator do opakowania:

 init(wrappedValue value: Value?) { self.value = value }

I to wszystko.

 @Email var email: String? = "[email protected]" print(email) // [email protected] @Email var email: String? = "invalid" print(email) // nil

Ostateczny kod opakowania znajduje się poniżej:

 @propertyWrapper struct Email<Value: StringProtocol> { var value: Value? init(wrappedValue value: Value?) { self.value = value } var wrappedValue: Value? { get { return validate(email: value) ? value : nil } set { value = newValue } } private func validate(email: Value?) -> Bool { guard let email = email else { return false } let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailPred.evaluate(with: email) } }

Konfigurowalne owijarki

Weźmy inny przykład. Piszesz grę i masz właściwość, w której przechowywane są wyniki użytkowników. Wymaganiem jest, aby ta wartość była większa lub równa 0 i mniejsza lub równa 100. Można to osiągnąć za pomocą opakowania właściwości.

 @propertyWrapper struct Scores { private let minValue = 0 private let maxValue = 100 private var value: Int init(wrappedValue value: Int) { self.value = value } var wrappedValue: Int { get { return max(min(value, maxValue), minValue) } set { value = newValue } } } @Scores var scores: Int = 0

Ten kod działa, ale nie wydaje się ogólny. Nie możesz go ponownie użyć z różnymi ograniczeniami (nie 0 i 100). Co więcej, może ograniczać tylko wartości całkowite. Lepiej byłoby mieć jedno konfigurowalne opakowanie, które może ograniczać dowolny typ zgodny z protokołem Comparable. Aby nasz wrapper był konfigurowalny, musimy dodać wszystkie parametry konfiguracyjne za pomocą inicjatora. Jeśli inicjator zawiera atrybut wrappedValue (wartość początkowa naszej właściwości), musi to być pierwszy parametr.

 @propertyWrapper struct Constrained<Value: Comparable> { private var range: ClosedRange<Value> private var value: Value init(wrappedValue value: Value, _ range: ClosedRange<Value>) { self.value = value self.range = range } var wrappedValue: Value { get { return max(min(value, range.upperBound), range.lowerBound) } set { value = newValue } } }

Aby zainicjować opakowaną właściwość, definiujemy wszystkie atrybuty konfiguracji w nawiasach po adnotacji.

 @Constrained(0...100) var scores: Int = 0

Liczba atrybutów konfiguracyjnych jest nieograniczona. Musisz je zdefiniować w nawiasach w tej samej kolejności, co w inicjatorze.

Uzyskiwanie dostępu do samego opakowania

Jeśli potrzebujesz dostępu do samego opakowania (nie do opakowanej wartości), musisz dodać podkreślenie przed nazwą właściwości. Weźmy na przykład naszą strukturę konta.

 struct Account { var firstName: String var lastName: String @Email var email: String? } let account = Account(firstName: "Test", lastName: "Test", email: "[email protected]") account.email // Wrapped value (String) account._email // Wrapper(Email<String>)

Potrzebujemy dostępu do samego wrappera, aby móc korzystać z dodatkowej funkcjonalności, którą do niego dodaliśmy. Na przykład chcemy, aby struktura konta była zgodna z protokołem Equatable. Dwa konta są równe, jeśli ich adresy e-mail są takie same, a w adresach e-mail nie można rozróżniać wielkości liter.

 extension Account: Equatable { static func ==(lhs: Account, rhs: Account) -> Bool { return lhs.email?.lowercased() == rhs.email?.lowercased() } }

Działa, ale nie jest to najlepsze rozwiązanie, ponieważ musimy pamiętać o dodaniu metody pisanej małymi literami (lowcased()) wszędzie tam, gdzie porównujemy e-maile. Lepszym sposobem byłoby ujednolicenie struktury wiadomości e-mail:

 extension Email: Equatable { static func ==(lhs: Email, rhs: Email) -> Bool { return lhs.wrappedValue?.lowercased() == rhs.wrappedValue?.lowercased() } }

i porównaj wrappery zamiast opakowanych wartości:

 extension Account: Equatable { static func ==(lhs: Account, rhs: Account) -> Bool { return lhs._email == rhs._email } }

Przewidywana wartość

Adnotacja @propertyWrapper zawiera jeszcze jeden cukier składni — przewidywaną wartość. Ta właściwość może mieć dowolny typ. Aby uzyskać dostęp do tej właściwości, musisz dodać przedrostek $ do nazwy właściwości. Aby wyjaśnić, jak to działa, użyjemy przykładu z frameworka Combine.

@Published tworzy wydawcę dla usługi i zwraca ją jako przewidywaną wartość.

 @Published var message: String print(message) // Print the wrapped value $message.sink { print($0) } // Subscribe to the publisher

Jak widać, używamy wiadomości, aby uzyskać dostęp do opakowanej właściwości, oraz $message, aby uzyskać dostęp do wydawcy. Co należy zrobić, aby dodać przewidywaną wartość do opakowania? Nic specjalnego, po prostu to zadeklaruj.

 @propertyWrapper struct Published<Value> { private let subject = PassthroughSubject<Value, Never>() var wrappedValue: Value { didSet { subject.send(wrappedValue) } } var projectedValue: AnyPublisher<Value, Never> { subject.eraseToAnyPublisher() } }

Jak wspomniano wcześniej, właściwość projectedValue może mieć dowolny typ w zależności od potrzeb.

Ograniczenia

Składnia nowego opakowania właściwości wygląda dobrze, ale zawiera również kilka ograniczeń, z których najważniejsze to:

  1. Nie mogą uczestniczyć w obsłudze błędów. Opakowana wartość jest właściwością (nie metodą) i nie możemy oznaczyć metody pobierającej ani ustawiającej jako throws . Na przykład w naszym przykładzie wiadomości Email nie można zgłosić błędu, jeśli użytkownik spróbuje ustawić nieprawidłowy adres e-mail. Możemy zwrócić nil lub zawiesić aplikację za pomocą fatalError() , co w niektórych przypadkach może być nie do przyjęcia.
  2. Stosowanie wielu opakowań do usługi jest niedozwolone. Na przykład lepiej byłoby mieć oddzielne opakowanie @CaseInsensitive i połączyć je z opakowaniem @Email , zamiast nie uwzględniać wielkości liter w opakowaniu @Email . Ale takie konstrukcje są zabronione i prowadzą do błędów kompilacji.
 @CaseInsensitive @Email var email: String?

W ramach obejścia tego konkretnego przypadku możemy odziedziczyć opakowanie Email z opakowania CaseInsensitive . Dziedziczenie ma jednak również ograniczenia — tylko klasy obsługują dziedziczenie i dozwolona jest tylko jedna klasa bazowa.

Wniosek

Adnotacje @propertyWrapper upraszczają składnię opakowań właściwości i możemy operować na opakowanych właściwościach w taki sam sposób, jak na zwykłych. Dzięki temu Twój kod jako programisty Swift jest bardziej kompaktowy i zrozumiały. Jednocześnie ma kilka ograniczeń, z którymi musimy się liczyć. Mam nadzieję, że niektóre z nich zostaną poprawione w przyszłych wersjach Swifta.

Jeśli chcesz dowiedzieć się więcej o właściwościach Swift, zapoznaj się z oficjalnymi dokumentami.