Fehler in Swift-Funktionen weitergeben (rethrows)

Die Array-Methode map ist ebenso wie unzählige weitere Collection-Methoden aus der Swift-Standardbibliothek mit dem Schlüsselwort rethrows deklariert:

// Definition von map in der Swift-Standardbibliothek
public struct Array<Element> : RandomAccessCollection, 
                               MutableCollection 
{
  public func map<T>(_ transform: (Element) throws -> T) rethrows 
                   -> [T]
}

Dieser Beitrag erklärt, was es mit rethrows auf sich hat und wie Sie rethrows in eigenen Funktionen bzw. Methoden verwenden.

Zum Verständnis dieses Artikels wäre es gut, wenn Sie die prinzipielle Idee der try/catch-Syntax von Swift kennen. Die Grundlagen habe ich in diesem Blog vor einiger Zeit präsentiert.

Das Schlüsselwort rethrows

rethrows kann anstelle von throws nach der Parameterliste, aber vor einem eventuellen Rückgabedatentyp angegeben. Das Schlüsselwort rethrows bedeutet für die obige map-Methode:

  • Wenn an map eine Funktion übergeben wird, die keinen Fehler auslöst, dann mussmap nicht abgesichert werden. map verspricht laut Deklaration, selbst keinen Fehler auszulösen.

  • Die gute Nachricht besteht darin, dass Sie map sogar eine Funktion oder Closure übergeben dürfen, die selbst einen Fehler auslösen kann. Deswegen ist der Parameter transform mit throws deklariert. Sollte bei der Ausführung von map tatsächlich ein Fehler auftreten, gibt map diesen Fehler zurück. Der Fehler wird also neuerlich ausgelöst (daher die Bezeichnung rethrows).

  • Die logische Konsequenz besteht aber darin, dass Sie — sobald Sie an map eine Funktion mit throws übergeben — den gesamten Aufruf von map selbst wieder absichern müssen.

Wie Sie anhand der folgenden Beispiele gleich sehen werden, gibt es dabei durchaus einige Sonderfälle zu beachten.

rethrows in der Praxis

Das folgende Beipiel geht davon aus, dass Sie eine Funktion f1 definiert haben, die unter bestimmten Umständen Fehler auslöst:

// eigene Fehlerzustände definieren
enum MyErrors : Error {
  case tooSmall
  case tooBig(maximum: Int)
  case missing
  case other(explanation: String)
}

// eigene Funktion, die Fehler auslösen kann
func f1(_ n: Int) throws -> Int {
  if n < 0   { throw MyErrors.tooSmall }
  if n > 100 { throw MyErrors.tooBig(maximum: 100) }
  return n + 1
}

Die zu verarbeitenden Daten befinden sich in einem Array:

let data = [1, 2, 3, 200]

Wenn Sie map auf data anwenden und dabei die Closure { $0 + 1} übergeben, kann nichts schief gehen. map muss nicht durch try abgesichert werden:

// new1 hat den Datentyp [Int], Ergebnis [2, 3, 4, 201]
let new1 = data.map() { $0 + 1}

Syntaktisch nicht erlaubt ist es hingegen, einfach f1 an map zu übergeben. Xcode reklamiert call can throw but is not marked with try, und meint damit, dass map jetzt sehr wohl einen Fehler auslösen kann — eben weil f1 mit throws deklariert ist.

let new2 = data.map(f1)   // Syntaxfehler, nicht erlaubt

Sie können sich jetzt für eine do-try-catch-Konstruktion entscheiden oder wie im folgenden Beispiel einfach try? voranstellen. new3 enthält in diesem Fall als Ergebnis nil, weil f1 beim data-Element 200 einen Fehler auslöst.

// new3 hat den Datentyp [Int]?, Ergebnis nil
let new3 = try? data.map(f1)

Auf den ersten Blick sieht die folgende Variante gleichwertig aus: f1 wird nicht unmittelbar als Parameter von map übergeben, sondern nachgestellt in eine Closure verpackt. Die Variante ist aber nicht gleichwertig. Durch die Verpackung betrachtet Swift die Closure als neue Funktion — und zwar als eine, die nicht mit throws deklariert ist. (Das ist syntaktisch in Swift nicht vorgesehen.)

let new4 = try? data.map() {  f1($0) }  // Syntaxfehler, nicht erlaubt

Der Compiler beklagt auch bei der obigen Zeile, dass ein Funktionsaufruf einen Fehler verursachen kann, aber nicht durch try abgesichert werden kann. Diesmal meint er aber nicht map, sondern f1. Und tatsächlich bietet ein try? vor f1($0) eine andere Variante, um f1 an map zu übergeben:

// new5 hat den Datentyp [Int?], Ergebnis
// [Optional(2), Optional(3), Optional(4), nil]
let new5 = data.map() { try? f1($0) }

Die Closure kann nun keinen Fehler mehr auslösen. Vielmehr liefert f1 entweder eine Integer-Zahl oder nil, der Datentyp ist also Int?. Deswegen erhält new5 den Datentyp [Int?], nicht [Int]? wie bei new3.

rethrows in eigenen Funktionen und Methoden

Wenn Sie selbst Funktionen oder Methoden mit rethrows kennzeichnen verpflichten Sie sich damit, selbst nur Fehler im catch-Teil der Fehlerabsicherung Ihrer Funktion bzw. Methode auszulösen. An anderen Stellen ist throws nicht erlaubt.

Die folgenden Zeilen zeigen eine minimalistische Neuimplementierung von map für Arrays — zum besseren Verständnis zuerst eine Variante ohne rethrows. Die transform-Funktion verarbeitet also die Array-Elemente vom Typ Element (dieser Name ist durch die Array-Definition vorgegeben) und liefert Ergebnisse vom Typ T. Dabei darf kein Fehler auftreten.

extension Array {
  func map1<T>(_ transform: (Element) -> T) -> [T] {
    var result = [T]()
    for item in self {
      result.append(transform(item))
    }
    return result
  }
}

Die map2-Variante akzeptiert wie die echte map-Methode Transformationsfunktionen mit throws und gibt gegebenenfalls die resultierenden Fehler mit rethrows weiter. Die Deklaration sieht jetzt wie bei der richtigen map-Methode aus. Die Implementierung ist möglicherweise weniger effizient, aber funktionell gleichwertig. Der Aufruf der transform-Funktion erfolgt mit try. Sollte dabei ein Fehler auftreten, wird
das resultierende Error-Objekt im catch-Zweig einfach durch throw an den Aufrufer von map weitergegeben. Hier findet also der Rethrow statt.

extension Array {
  func map2<T>(_ transform: (Element) throws -> T) rethrows 
         -> [T] 
  {
    var result = [T]()
    for item in self {
      do {
        let newitem = try transform(item)
        result.append(newitem)
      } catch {
        throw error
      }
    }
    return result
  }
}

Swifts Syntax erlaubt eine noch kürzere Variante, die auf do und catch verzichtet. try ist weiter vorgeschrieben und hat dieselbe Wirkung wie die gesamte do-try-catch-Konstruktion von map2:

extension Array {
  func map3<T>(_ transform: (Element) throws -> T) rethrows 
         -> [T] 
  {
    var result = [T]()
    for item in self {
      let newitem = try transform(item)
      result.append(newitem)
    }
    return result
  }
}

Der obige Code verpackt in erstaunlich wenigen Zeilen viele fortgeschrittene Swift-Sprachmerkmale: neben der Fehlerbehandlung sind zur Definition einer neuen map-Methode für Arrays auch Extensions und Generics mit im Spiel.

Ein kurzer Test stellt sicher, dass sie map3 wie map verhält:

let new7 = try? data.map3(f1)            
// new7: Datentyp [Int]?, Ergebnis nil

let new8 = data.map3() { try? f1($0) }  
// new8: Datentyp [Int?], 
// Ergebnis [Optional(2), Optional(3), Optional(4), nil]

Quellen