I Swift är kopplade värden en kraftfull funktion som gör det möjligt att associera ytterligare data med varje enums alternativ. Detta gör det möjligt att representera mer komplexa datatyper och att göra koden mer flexibel och läsbar. När vi använder kopplade värden kan varje enum-case ha sitt eget specifika dataformat, vilket gör att vi kan anpassa varje fall för olika användningsområden. Låt oss titta närmare på hur denna funktion fungerar och varför den är så användbar.

För att börja kan vi använda ett exempel där vi definierar en enum för produkter, som antingen kan vara en bok eller ett pussel:

swift
enum Product {
case book(price: Double, yearPublished: Int, pageCount: Int) case puzzle(price: Double, pieceCount: Int) }

I detta exempel definieras två cases, book och puzzle, med kopplade värden. När vi vill skapa ett produktobjekt använder vi dessa värden för att skapa specifika instanser:

swift
let masterSwift = Product.book(price: 49.99, yearPublished: 2024, pageCount: 394)
let worldPuzzle = Product.puzzle(price: 9.99, pieceCount: 200)

Genom att använda ett switch-uttryck kan vi nu hantera dessa värden på ett effektivt sätt:

swift
switch masterSwift {
case .book(let price, let year, let pages):
print("Mastering Swift was published in \(year) for the price of \(price) and has \(pages) pages.")
case .puzzle(let price, let pieces): print("Product is a puzzle with \(pieces) pieces and sells for \(price).") }

Det här är en enkel men effektiv metod för att bearbeta olika typer av data som lagras i en enum. Eftersom varje fall kan hålla sina egna specifika data, kan vi snabbt och enkelt skapa kod som är både flexibel och lätt att läsa.

För att göra koden ännu mer läsbar kan vi använda etiketter för att ge varje kopplat värde ett namn. Så här kan vi skriva om vår Product-enum med etiketter:

swift
enum ProductWithLabels {
case book(price: Double, yearPublished: Int, pageCount: Int) case puzzle(price: Double, pieceCount: Int) }

Med denna uppdatering blir koden mer intuitiv:

swift
let masterSwift = ProductWithLabels.book(price: 49.99, yearPublished: 2024, pageCount: 394)

Det här ger oss både mer läsbar och begriplig kod, vilket gör det lättare att förstå vilken typ av data vi arbetar med i varje fall.

Mönstermatchning

Mönstermatchning är en annan viktig funktion i Swift som gör det möjligt att jämföra värden mot ett uppsättning mönster och utföra motsvarande kodblock beroende på vilken typ av data vi får. Detta är särskilt användbart när vi arbetar med enumerations, eftersom varje case kan ha sina egna kopplade värden.

För att visa hur mönstermatchning fungerar, kan vi skapa en ny enum för väderförhållanden:

swift
enum Weather {
case sunny case cloudy case rainy(Int) case snowy(amount: Int) }

Här definierar vi fyra väderalternativ, varav två har kopplade värden (regn och snö). För att visa vädret kan vi använda ett switch-uttryck:

swift
func showWeather(_ weather: Weather) { switch weather { case .sunny: print("It's sunny") case .cloudy: print("It's cloudy") case .rainy(let intensity): print("It's raining with an intensity of \(intensity).") case .snowy(let amount): print("It's snowing with an estimated amount of \(amount).") } }

Genom att använda mönstermatchning kan vi extrahera de kopplade värdena från de fall som har sådana och på så sätt visa specifik information beroende på vädret. Om vi kallar på showWeather(.rainy(2)), kommer resultatet att vara:

rust
It's raining with an intensity of 2.

Vi kan också matcha flera cases samtidigt i ett enda uttryck:

swift
func showPrecipitation(_ weather: Weather) { switch weather { case .sunny, .cloudy: print("No precipitation today") case .rainy(let intensity): print("It rained with an intensity of \(intensity).") case .snowy(let amount): print("It snowed \(amount) inches.") } }

När vi kör funktionen med väderalternativet .sunny, får vi svaret:

yaml
No precipitation today

Iteration över en enum

En annan användbar funktion i Swift är möjligheten att iterera över alla värden i en enum. Detta kan göras genom att implementera CaseIterable-protokollet, vilket gör det möjligt att få tillgång till alla cases i en enum:

swift
enum DaysOfWeek: String, CaseIterable { case Monday = "Mon" case Tuesday = "Tues" case Wednesday = "Wed" case Thursday = "Thur" case Friday = "Fri" case Saturday = "Sat" case Sunday = "Sun" } for day in DaysOfWeek.allCases { print(" -- \(day.rawValue): \(day)") }

Denna kod skriver ut alla veckodagar, en per rad:

lua
-- Mon: Monday
-- Tues: Tuesday -- Wed: Wednesday -- Thur: Thursday -- Fri: Friday -- Sat: Saturday -- Sun: Sunday

Vi kan även filtrera veckodagar för att endast visa vardagarna:

swift
for weekDay in DaysOfWeek.allCases.filter({ $0 != .Saturday && $0 != .Sunday }) { print(" -- \(weekDay).") }

Resultatet blir:

lua
-- Mon.
-- Tues. -- Wed. -- Thur. -- Fri.

Denna flexibilitet gör det möjligt att på ett enkelt sätt iterera över specifika värden i en enum och anpassa koden efter behov.

Viktiga aspekter att förstå

Vid arbete med enumerations och kopplade värden är det viktigt att förstå att Swift erbjuder mycket mer än bara grundläggande matchning och iteration. Funktionen för mönstermatchning gör det möjligt att bygga dynamisk och flexibel kod som kan reagera på olika typer av data beroende på användarens behov. Genom att använda etiketter och kopplade värden blir koden mer läsbar och hanterbar. När man arbetar med enum-iterering kan man effektivt hantera olika scenarier och anpassa sitt program beroende på de data man arbetar med.

Hur man hanterar samtidighet i Swift genom att implementera Sendable-protokollet och fortsättningar

I Swift, särskilt efter introduktionen av strukturerad samtidighet i version 5.5, har hantering av samtidiga operationer blivit både enklare och säkrare. För att säkerställa att data kan användas på ett trådsäkert sätt över olika trådar och uppgifter, måste typer som delas mellan trådar följa vissa regler, vilket sker genom att implementera protokollet Sendable. Detta protokoll är kärnan i Swift:s samtidighetsmodell, och att förstå när och hur man kan använda det är avgörande för att undvika samtidighetsproblem som datarace och synkroniseringsfel.

När du arbetar med anpassade typer och vill säkerställa att dessa är trådsäkra, finns det specifika regler som måste följas. För det första, alla aktörer i Swift konformerar automatiskt till Sendable-protokollet. Aktörer är specialiserade objekt som säkerställer att deras inre tillstånd är skyddade från parallell åtkomst, vilket gör dem säkra att använda i samtidiga operationer. För andra, anpassade värdetyper som strukturer eller enumerationer konformerar också till Sendable, förutsatt att de enbart innehåller medlemmar som redan konformerar till protokollet. Det innebär att om en struktur endast har värdetyper som är trådsäkra, kan den själv också användas utan risk för samtidighetsproblem.

För referenstyper, som klasser, är det dock mer komplicerat. För att en klass ska konformera till Sendable, måste den uppfylla specifika krav: den får inte ärva från en annan referenstyp, alla egenskaper i klassen måste vara konstanta (let), och alla dessa egenskaper måste konformera till Sendable. Dessutom är det en fördel att markera klassen som final för att hindra ytterligare ärvning, vilket säkerställer att ingen annan klass kan åsidosätta de trådsäkerhetsgarantier som klassen erbjuder.

Låt oss ta ett exempel på en klass som konformerar till Sendable. Tänk dig att vi definierar en typ för en transaktion i en bankapplikation:

swift
struct Transaction: Sendable {
let id: Int let amount: Double let description: String }

Denna typ är säker att använda i samtidiga sammanhang eftersom alla egenskaper (id, amount, och description) är värdetyper som själva konformerar till Sendable. Detta gör att en instans av Transaction kan användas utan problem i olika trådar eller uppgifter.

För att visa hur vi kan använda denna typ i en aktör, definierar vi en enkel aktör som representerar ett bankkonto:

swift
actor BankAccount {
private var transactions = [Transaction]()
func addTransaction(_ transaction: Transaction) {
transactions.append(transaction) } }

I denna aktör lagrar vi en lista över transaktioner, och metoden addTransaction lägger till en ny transaktion i listan. Eftersom både Transaction och BankAccount konformerar till Sendable är denna kod säker att använda i samtidiga sammanhang.

Det kan dock uppstå situationer där kompilatorn inte kan verifiera att en typ är säker för samtidigt bruk, trots att vi vet att den är det. Detta kan ske med klasser som använder interna synkroniseringsmekanismer, som lås, eller när en typ innehåller medlemmar som inte själva är trådsäkra. I sådana fall kan vi använda attributet @unchecked Sendable för att uttryckligen tala om för kompilatorn att vi har säkerställt trådsäkerheten själva.

Exempelvis, om vi har en klass som använder ett lås för att hantera samtidiga åtkomster till en räknare, kan vi deklarera den som @unchecked Sendable:

swift
class Counter: @unchecked Sendable {
private var count = 0 private let lock = NSLock() func increment() { lock.lock() defer { lock.unlock() } count += 1 } func getCount() -> Int { lock.lock() defer { lock.unlock() } return count } }

I detta exempel säkerställer NSLock att endast en tråd kan modifiera count-variabeln åt gången, vilket ger trådsäkerhet. Eftersom kompilatorn inte kan analysera beteendet hos låset, måste vi informera den om att vi har säkerställt trådsäkerheten själva genom att använda @unchecked Sendable. Det är viktigt att förstå att detta innebär ett ansvar; vi måste själva se till att koden verkligen är trådsäker, eftersom kompilatorn inte kommer att kunna kontrollera detta.

En annan aspekt av Swift:s samtidighetsmodell är övergången från traditionella completion handlers till den moderna async/await-modellen. Många äldre API:er använder fortfarande completion handlers, vilket gör det svårt att integrera dem i den nya samtidighetsmodellen. För att lösa detta kan vi använda funktionerna withCheckedContinuation och withUnsafeContinuation. Dessa funktioner gör det möjligt att pausa exekveringen av en asynkron funktion och återuppta den när en callback har slutförts.

Till exempel, om vi har en funktion som hämtar användardata med en completion handler:

swift
func fetchUserData(completion: @escaping (Result<String, Error>) -> Void) { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { completion(.success("User Data fetched!")) } }

För att integrera denna funktion i den nya async/await-modellen kan vi använda withCheckedThrowingContinuation:

swift
func fetchUserDataAsync() async throws -> String {
try await withCheckedThrowingContinuation { continuation in fetchUserData { result in switch result { case .success(let user): continuation.resume(returning: user) case .failure(let error): continuation.resume(throwing: error) } } } }

Med denna funktion kan vi nu använda fetchUserDataAsync() som en vanlig asynkron funktion. Den gamla completion handlern används fortfarande, men vi hanterar den på ett sätt som gör koden mer läsbar och hanterbar.

Swift:s struktur för samtidighet har verkligen förenklat asynkron programmering, och med hjälp av fortsättningar kan vi enkelt anpassa äldre API:er till den nya modellen. Detta gör att vi kan skriva renare, mer läsbar kod utan att förlora kompatibilitet med äldre system.

I Swift 6 introduceras också strikt samtidighetskontroll, vilket ytterligare förbättrar säkerheten och korrektheten när vi arbetar med samtidiga operationer. Det är en funktion som kan hjälpa till att fånga potentiella samtidighetsproblem redan under kompileringen, vilket gör koden ännu säkrare.

Hur protokollorienterad design förbättrar kodens säkerhet och modularitet

Inom protokollorienterad design delar vi upp krav i separata protokoll för olika funktionaliteter. Detta gör koden mer säker, lättare att underhålla och mer modulär. När gemensam funktionalitet behövs, kan vi enkelt lägga till en protokollextension till ett eller flera protokoll. En fördel med denna design är att egenskaper definieras med enbart get-attributet, vilket innebär att de kommer att definieras som konstanter inom den typ som uppfyller protokollet. Detta förhindrar att externa delar av koden kan ändra värdena när de väl är satta, vilket är en betydande fördel jämfört med traditionella objektorienterade lösningar.

Låt oss nu titta på hur vi kan skapa typer som följer dessa protokoll. Vi implementerar de samma typerna – Tank, Amphibious och Transformer – som vi använde i det objektorienterade designmönstret. Vi börjar med att titta på Tank-typen:

swift
struct Tank: LandVehicle {
var hitPoints = 68 let landAttackRange = 5 let landAttack = true let landMovement = true func doLandAttack() { print("Tank Attack") } func doLandMovement() { print("Tank Move") } }

Det finns flera skillnader mellan den här Tank-typen och den vi såg i det objektorienterade designmönstret. Först och främst är Tank i protokollorienterad design en struktur, en värdetyp, medan Tank i det objektorienterade designmönstret är en klass, en referens typ. Trots att protokollorienterad design inte tvingar oss att använda värdetyper, är de generellt att föredra. En av de största fördelarna med att använda värdetyper är säkerhet. Med värdetyper får vi alltid en unik kopia av en instans, vilket garanterar typ-säkerhet och förhindrar att en tråd ändrar data medan en annan tråd använder den, vilket kan orsaka svårupptäckta och svårt återskapade buggar.

En annan skillnad är att Tank i den protokollorienterade designen använder standard-initialiseraren från strukturen, vilket gör att vi kan definiera egenskaper som konstanter. Dessa kan inte ändras när de väl har satts. I kontrast krävs det i objektorienterad design att vi skriver om initialiseraren och definierar egenskaper som variabler, vilket innebär att de kan ändras efter att de har satts.

Dessutom har Tank i den protokollorienterade designen endast funktionalitet för landfordon, medan den i objektorienterad design ärver funktionalitet för både sjö- och luftfordon. Detta innebär att den objektorienterade versionen kan inkludera fler funktioner än vad som egentligen behövs för den specifika uppgiften.

Låt oss nu se hur Amphibious-typen kan implementeras:

swift
struct Amphibious: LandVehicle, SeaVehicle {
var hitPoints = 25 let landAttackRange = 1 let seaAttackRange = 1 let landAttack = true let landMovement = true let seaAttack = true let seaMovement = true func doLandAttack() { print("Amphibious Land Attack") } func doLandMovement() { print("Amphibious Land Move") } func doSeaAttack() { print("Amphibious Sea Attack") } func doSeaMovement() { print("Amphibious Sea Move") } }

Amphibious-typen är lik Tank, men den använder protokollkomposition för att följa både LandVehicle och SeaVehicle. Detta gör att Amphibious kan använda funktionalitet definierad i både land- och sjöprotokoll. På samma sätt kan Transformer-typen skapas genom att använda protokollkomposition för att följa LandVehicle, SeaVehicle och AirVehicle:

swift
struct Transformer: LandVehicle, SeaVehicle, AirVehicle {
var hitPoints = 75 let landAttackRange = 7 let seaAttackRange = 5 let airAttackRange = 6 let landAttack = true let landMovement = true let seaAttack = true let seaMovement = true let airAttack = true let airMovement = true func doLandAttack() { print("Transformer Land Attack") } func doLandMovement() { print("Transformer Land Move") } func doSeaAttack() { print("Transformer Sea Attack") } func doSeaMovement() { print("Transformer Sea Move") } func doAirAttack() { print("Transformer Air Attack") } func doAirMovement() { print("Transformer Air Move") } }

Eftersom Transformer-typen kan operera över alla tre terrängtyper använder vi här protokollkomposition för att följa LandVehicle, SeaVehicle och AirVehicle. Detta gör att Transformer kan utföra alla de handlingar som definieras i dessa protokoll.

För att använda dessa typer tillsammans, som i objektorienterad design, behöver vi hålla instanser av alla fordonstyper i en samling. Detta gör det möjligt att iterera genom alla aktiva fordon och utföra nödvändiga åtgärder. Här använder vi polymorfism för att arbeta med fordonen, men istället för att använda klasser och ärvda metoder som i objektorienterad design, använder vi gränssnittet som definieras av protokollen:

swift
var vehicles = [Vehicle]()
var vh1 = Amphibious() var vh2 = Amphibious() var vh3 = Tank() var vh4 = Transformer() vehicles.append(vh1) vehicles.append(vh2) vehicles.append(vh3) vehicles.append(vh4) for (index, vehicle) in vehicles.enumerated() { if vehicle is AirVehicle { print("Vehicle at \(index) is Air") } if vehicle is LandVehicle { print("Vehicle at \(index) is Land") } if vehicle is SeaVehicle { print("Vehicle at \(index) is Sea") } }

Med denna kod itererar vi genom fordonen och kontrollerar om varje fordon är av en specifik typ, som AirVehicle, LandVehicle eller SeaVehicle. På detta sätt kan vi arbeta med dessa objekt på ett flexibelt sätt, där varje typ kan hantera sina egna funktioner.

I protokollorienterad design, genom att strikt definiera typer och deras funktionalitet i separata protokoll, kan vi uppnå en högre grad av säkerhet och modularitet. Designen uppmuntrar till att hålla funktioner separerade och gör det möjligt att lätt addera ny funktionalitet genom att skapa nya protokoll eller använda protokollextensioner. Detta gör koden mer skalbar och lättare att underhålla.

Vad är closures och hur använder vi dem för att skriva effektivare kod?

Closures är självständiga kodblock som kan användas och skickas runt i vår applikation. I Swift kan closures betraktas som typer som håller en bit kod, på samma sätt som typen Int håller ett heltal eller typen String håller en sträng. Vi kan alltså tilldela closures till variabler, skicka dem som argument till funktioner och även returnera dem från funktioner. Detta gör att closures ger oss flexibiliteten att skapa funktionella och uttrycksfulla kodblock som vi kan återanvända.

En av de mest intressanta egenskaperna hos closures är deras förmåga att "stänga över" (capture) variabler eller konstanter från den kontext de skapades i. Det innebär att closures kan behålla referenser till variabler eller konstanter även efter att de har skapats, vilket gör dem användbara i många olika sammanhang. Swift hanterar minneshantering automatiskt i de flesta fall, men ibland kan starka referenscykler uppstå, vilket vi kommer att gå igenom mer ingående i kapitel 15 om minneshantering.

En closure i Swift definieras med en syntax som påminner om funktioner, men den skiljer sig genom att använda nyckelordet in för att separera parametrarna och returtypen från kodblocket. Funktionen i Swift måste alltid ha ett namn, medan en closure inte behöver det.

Exempel på en enkel closure:

swift
let clos1 = { () -> Void in print("Hello World") }

I detta exempel skapar vi en closure som inte accepterar några parametrar och inte returnerar något värde. Det enda den gör är att skriva ut "Hello World" till konsolen. För att köra denna closure anropar vi den som en funktion: clos1(). Detta är ett enkelt exempel, men closures har en enorm potential när de används på rätt sätt i mer komplexa sammanhang.

Vi kan också skapa closures som accepterar parametrar. Till exempel en closure som tar en sträng och skriver ut ett meddelande:

swift
let clos2 = { (name: String) -> Void in print("Hello \(name)") }

Här ser vi hur closure-syntaxen definieras med en parameter name av typen String, som används i kodblocket för att skriva ut ett personligt meddelande. Vi kan använda denna closure genom att kalla den med ett argument: clos2("Jon"), vilket kommer att skriva ut "Hello Jon".

En annan viktig aspekt av closures är att de kan skickas som parametrar till funktioner. Vi kan till exempel skapa en funktion som accepterar en closure som argument och använder den på något sätt. Här är ett exempel:

swift
func testClosure(handler: (String) -> Void) { handler("Luna") }

I detta exempel definierar vi en funktion testClosure som tar en closure som parameter, och när vi anropar funktionen med testClosure(handler: clos2), kommer den att skriva ut "Hello Luna" till konsolen eftersom den använder den closure vi skickar till den.

En viktig aspekt av closures är att de kan returnera värden, vilket gör dem ännu mer användbara i många situationer. Till exempel kan vi skapa en closure som tar en parameter och returnerar en sträng:

swift
let clos3 = { (name: String) -> String in return "Hello \(name)" }

När vi anropar denna closure med clos3("Maple") får vi tillbaka strängen "Hello Maple", vilket vi sedan kan använda på andra sätt i koden.

När vi arbetar med closures finns det flera sätt att skriva dem mer kortfattat, vilket kan göra koden ännu mer expressiv men också ibland svårare att läsa för andra utvecklare. För enkla closures används ofta en kortfattad syntax som gör koden mer kompakt och lätt att skriva:

swift
let clos4 = { name in print("Hello \(name)") }

I detta fall har vi tagit bort både typen på parametern och returtypen, vilket gör syntaksen mer komprimerad. Detta kan vara användbart när vi har små closures som används i begränsade sammanhang, men det kan också göra koden mindre tydlig för andra utvecklare som inte är vana vid denna kortfattade syntax.

Det är också viktigt att förstå när det är lämpligt att använda closures i större projekt. De är kraftfulla verktyg för att skriva kod som är både effektiv och lätt att förstå, men de kräver också noggrann hantering av referenser och minneshantering för att undvika problem som starka referenscykler. När closures används korrekt, kan de förenkla komplicerade kodstrukturer och göra dem mer läsbara, vilket är avgörande för att underlätta vidare utveckling och underhåll av kodbasen.

Det är också viktigt att komma ihåg att closures i Swift kan användas för mer avancerade tekniker, som att skapa så kallade "result builders", som är särskilt användbara när man arbetar med dataflöden eller DSL (Domain Specific Languages). Result builders gör det möjligt att skriva uttrycksfull kod som lätt kan läsa och förstås, särskilt när det handlar om att skapa komplexa datastrukturer eller sammansatta resultat från olika delar av koden.