Swiftlang
Art
Projekt 11 von 12
Niveau
Grundlagen
Lesezeit
etwa 4 Minuten
Stand

Projekt: ein Wortzähler

In diesem Projekt baust du ein Kommandozeilen-Werkzeug, das Text von der Standardeingabe liest und ausgibt, wie viele Wörter er enthält, welche am häufigsten vorkommen und welches das längste ist. Du nutzt dabei Strings, Collections, Closures, Optionals und Structs, trennst Logik von Ein- und Ausgabe und sicherst die Logik mit Prüfungen ab.

Danach kannst du

  • legst ein ausführbares Paket mit dem Swift Package Manager an
  • trennst testbare Logik von Ein- und Ausgabe
  • liest Standardeingabe und Kommandozeilen-Argumente
  • sicherst Verhalten mit Prüfungen und Tests ab

Umgebung

Swift 6.4.0, Sprachmodus 6
macOS, Linux, Windows; Logik und Programm geprüft unter Linux x86-64

Package.swift und die Swift-Testing-Datei wurden nicht ausgeführt, weil der Prüfdienst keine Pakete baut. Die Programmlogik und das Hauptprogramm wurden mit derselben Quelle ausgeführt.

Was du baust

Am Ende rufst du das Programm so auf und bekommst eine kleine Statistik:

TerminalBefehl nicht auf Swiftlang ausgeführt
swift run wortzaehler --top 3 < text.txt

Der Ablauf hat drei Teile, und genau so ist auch der Code aufgeteilt:

  1. Einlesen: Text Zeile für Zeile von der Standardeingabe lesen, das Argument --top auswerten.
  2. Auswerten: Wörter finden, zählen, sortieren. Das ist reine Logik ohne Ein- und Ausgabe.
  3. Ausgeben: Das Ergebnis als lesbaren Text formatieren.

Die Trennung ist die wichtigste Entscheidung im Projekt. Die Auswertung bekommt einen String und liefert einen Wert zurück. Sie weiß nichts von Terminal oder Dateien. Deshalb kannst du sie mit wenigen Zeilen prüfen, ohne das Programm zu starten.

Das Paket anlegen

TerminalBefehl nicht auf Swiftlang ausgeführt
mkdir wortzaehler
cd wortzaehler
swift package init --name wortzaehler --type executable

Ersetze danach die erzeugte Package.swift durch diese Fassung mit einem Testziel:

Package.swiftNicht ausgeführt Ausschnitt oder Paketdatei, siehe Text
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "wortzaehler",
    targets: [
        .executableTarget(name: "wortzaehler"),
        .testTarget(name: "wortzaehlerTests", dependencies: ["wortzaehler"]),
    ]
)

Die Ordnerstruktur sieht dann so aus:

OrdnerstrukturNicht ausgeführt Ausschnitt oder Paketdatei, siehe Text
wortzaehler/
├── Package.swift
├── Sources/
│   └── wortzaehler/
│       ├── main.swift
│       └── Wortstatistik.swift
└── Tests/
    └── wortzaehlerTests/
        └── WortstatistikTests.swift

Die Logik: Wortstatistik.swift

Wortstatistik.swiftAusgeführt Swift 6.4.0, Linux x86-64, 19.10.2026
/// Ergebnis einer Analyse. Ein Werttyp: einmal berechnet, danach unveränderlich.
struct Wortstatistik: Equatable {
    struct Eintrag: Equatable {
        let wort: String
        let anzahl: Int
    }

    let woerter: Int
    let verschiedene: Int
    let laengstes: String?
    let haeufigste: [Eintrag]
}

/// Zerlegt Text in Wörter: alles, was weder Buchstabe noch Ziffer ist, trennt.
/// "Swift-Code" wird so zu "swift" und "code" (siehe Übung 2).
func woerter(in text: String) -> [String] {
    text
        .split(whereSeparator: { !($0.isLetter || $0.isNumber) })
        .map { $0.lowercased() }
}

func analysiere(_ text: String, top: Int = 5) -> Wortstatistik {
    let alle = woerter(in: text)

    var zaehlung: [String: Int] = [:]
    for wort in alle {
        zaehlung[wort, default: 0] += 1
    }

    // Häufigste zuerst, bei Gleichstand alphabetisch: so ist die Ausgabe immer gleich
    let sortiert = zaehlung
        .map { Wortstatistik.Eintrag(wort: $0.key, anzahl: $0.value) }
        .sorted { $0.anzahl != $1.anzahl ? $0.anzahl > $1.anzahl : $0.wort < $1.wort }

    let laengstes = zaehlung.keys.max { a, b in
        a.count != b.count ? a.count < b.count : a > b
    }

    return Wortstatistik(
        woerter: alle.count,
        verschiedene: zaehlung.count,
        laengstes: laengstes,
        haeufigste: Array(sortiert.prefix(max(0, top)))
    )
}

func bericht(_ s: Wortstatistik) -> String {
    var zeilen = [
        "Wörter:        \(s.woerter)",
        "Verschiedene:  \(s.verschiedene)",
        "Längstes Wort: \(s.laengstes ?? "–")",
    ]
    if !s.haeufigste.isEmpty {
        zeilen.append("Häufigste:")
        for eintrag in s.haeufigste {
            zeilen.append("  \(eintrag.anzahl)× \(eintrag.wort)")
        }
    }
    return zeilen.joined(separator: "\n")
}

Ausgabe

(keine Ausgabe: die Datei definiert nur Typen und Funktionen)

Ein paar Entscheidungen im Detail:

  • Was ist ein Wort? split(whereSeparator:) trennt an jedem Zeichen, das weder Buchstabe noch Ziffer ist. Character.isLetter kennt Unicode, deshalb bleiben „Größe“ und „über“ ganze Wörter. Bindestriche trennen dagegen: Aus „Swift-Code“ werden zwei Wörter. Das ist eine bewusste Vereinfachung, Übung 2 ändert sie.
  • Groß und klein: lowercased() sorgt dafür, dass „Swift“ und „swift“ dasselbe Wort sind.
  • Stabile Reihenfolge: Ein Dictionary hat keine feste Reihenfolge. Ohne die zweite Sortierregel bei Gleichstand könnte die Ausgabe bei jedem Lauf anders aussehen. Das wäre für Tests ein Albtraum.
  • Wortstatistik ist ein Struct mit let-Eigenschaften. Einmal berechnet, ändert sich nichts mehr. Equatable erlaubt, zwei Ergebnisse mit == zu vergleichen, was die Prüfungen gleich nutzen.

Die Datei allein gibt nichts aus, sie definiert nur Typen und Funktionen.

Das Hauptprogramm: main.swift

main.swiftAusgeführt Swift 6.4.0, Linux x86-64, 19.10.2026 zusammen mit Wortstatistik.swift
/// Liest Text von der Standardeingabe und gibt eine Wortstatistik aus.
/// Aufruf: swift run wortzaehler --top 3 < text.txt

func anzahlHaeufigste(aus argumente: [String]) -> Int? {
    guard let index = argumente.firstIndex(of: "--top") else { return 5 }
    let wertIndex = argumente.index(after: index)
    guard wertIndex < argumente.endIndex, let wert = Int(argumente[wertIndex]), wert >= 0 else {
        return nil
    }
    return wert
}

if let top = anzahlHaeufigste(aus: Array(CommandLine.arguments.dropFirst())) {
    var text = ""
    while let zeile = readLine() {
        text += zeile + "\n"
    }
    print(bericht(analysiere(text, top: top)))
} else {
    print("Verwendung: wortzaehler [--top ANZAHL] < datei.txt")
}

Aufruf

main --top 3

Eingabe

Wer Swift lernt, lernt zuerst Werte und Typen.
Dann lernt man Funktionen, dann Optionals, dann Protokolle.
Ein Werkzeug, das Wörter zählt, ist ein kleines Projekt, aber ein echtes Projekt:
Es liest Eingaben, verarbeitet Text und gibt ein Ergebnis aus. Übung macht den Unterschied.

Ausgabe

Wörter:        43
Verschiedene:  34
Längstes Wort: unterschied
Häufigste:
  4× ein
  3× dann
  3× lernt

main.swift ist die einzige Datei im Paket, die Code auf oberster Ebene enthalten darf; dort beginnt das Programm. CommandLine.arguments enthält als erstes Element den Programmnamen, deshalb dropFirst(). readLine() liefert die nächste Zeile oder nil, wenn die Eingabe zu Ende ist, ein Optional, das die while let-Schleife beendet.

Die Argumentprüfung ist absichtlich streng: --top abc oder --top -1 liefern nil, und das Programm zeigt einen Hinweis zur Verwendung, statt mit einem geratenen Wert weiterzumachen.

Die Ausgabe oben ist echt: Das Programm wurde mit dem gezeigten Text als Eingabe und --top 3 ausgeführt, zusammen mit Wortstatistik.swift. „ein“ steht vor „dann“ und „lernt“, weil es viermal vorkommt; „dann“ steht vor „lernt“, weil beide dreimal vorkommen und „d“ alphabetisch vor „l“ kommt.

Prüfen, ob die Logik stimmt

Bevor das Programm wächst, sicherst du sein Verhalten ab. Diese Prüfungen laufen als normales Programm zusammen mit Wortstatistik.swift. Scheitert eine, bricht precondition mit der Beschreibung ab:

pruefungen.swiftAusgeführt Swift 6.4.0, Linux x86-64, 19.10.2026 zusammen mit Wortstatistik.swift
// Prüft die Logik aus Wortstatistik.swift. Jede Prüfung bricht das Programm ab, wenn sie scheitert.
struct Pruefer {
    private(set) var bestanden = 0

    mutating func pruefe(_ bedingung: Bool, _ beschreibung: String) {
        precondition(bedingung, "Fehlgeschlagen: \(beschreibung)")
        bestanden += 1
    }
}

var p = Pruefer()
p.pruefe(woerter(in: "") == [], "leerer Text hat keine Wörter")
p.pruefe(woerter(in: "Hallo, Welt!") == ["hallo", "welt"], "Satzzeichen trennen")
p.pruefe(woerter(in: "Größe ÄRGER über") == ["größe", "ärger", "über"], "Umlaute bleiben erhalten")
p.pruefe(woerter(in: "Swift 6 ist da") == ["swift", "6", "ist", "da"], "Ziffern zählen als Wort")

let s = analysiere("b a b c b a", top: 2)
p.pruefe(s.woerter == 6 && s.verschiedene == 3, "Zählung")
p.pruefe(s.haeufigste == [.init(wort: "b", anzahl: 3), .init(wort: "a", anzahl: 2)], "Reihenfolge der Häufigsten")
p.pruefe(analysiere("x y", top: 5).haeufigste.map(\.wort) == ["x", "y"], "Gleichstand alphabetisch")
p.pruefe(analysiere("kurz länger", top: 0).haeufigste.isEmpty, "top 0 liefert keine Liste")
p.pruefe(analysiere("ab cd").laengstes == "ab", "längstes Wort bei Gleichstand stabil")
p.pruefe(analysiere("").laengstes == nil, "leerer Text hat kein längstes Wort")

print("Alle \(p.bestanden) Prüfungen bestanden.")

Ausgabe

Alle 10 Prüfungen bestanden.

Die Fälle sind nicht zufällig gewählt. Sie decken die Grenzen ab: leerer Text, Satzzeichen, Umlaute, Ziffern, Gleichstand, top gleich 0. Genau an solchen Grenzen entstehen die meisten Fehler.

Beim ersten Entwurf stand der Zähler bestanden übrigens als globale Variable da, verändert von einer normalen Funktion. Swift 6 hat das abgelehnt: Globale veränderliche Variablen gehören im Sprachmodus 6 zum Main Actor, und eine Funktion ohne diese Zuordnung darf sie nicht ändern. Das kleine Struct Pruefer hält den Zähler stattdessen als normalen Wert.

Tests mit Swift Testing

Im Paket schreibst du Tests mit dem Framework Swift Testing, das zur aktuellen Toolchain gehört. Die Datei kommt nach Tests/wortzaehlerTests/ und läuft mit swift test:

WortstatistikTests.swiftNicht ausgeführt Ausschnitt oder Paketdatei, siehe Text
import Testing
@testable import wortzaehler

@Test func satzzeichenTrennen() {
    #expect(woerter(in: "Hallo, Welt!") == ["hallo", "welt"])
}

@Test func umlauteBleibenErhalten() {
    #expect(woerter(in: "Größe ÄRGER über") == ["größe", "ärger", "über"])
}

@Test func haeufigsteMitGleichstand() {
    let s = analysiere("b a b c b a", top: 2)
    #expect(s.woerter == 6)
    #expect(s.haeufigste.map(\.wort) == ["b", "a"])
}

@Test(arguments: ["", "   ", "!!!"])
func ohneWoerter(text: String) {
    #expect(analysiere(text).woerter == 0)
}

@Test markiert eine Testfunktion, #expect prüft eine Bedingung und zeigt bei einem Fehlschlag beide Seiten des Vergleichs. Mit arguments: läuft ein Test einmal pro Wert. @testable import erlaubt dem Test, auch Funktionen ohne public zu sehen. Diese Datei haben wir nicht ausgeführt, siehe Hinweis oben; die Prüfungen, die sie spiegelt, liefen im vorigen Abschnitt.

Starten

TerminalBefehl nicht auf Swiftlang ausgeführt
swift build
echo "Swift ist schnell. Swift ist sicher." | swift run wortzaehler --top 2
swift test

swift build übersetzt, swift run startet, swift test führt die Tests aus. Das Weiterleiten mit | funktioniert auch in PowerShell. Das Umleiten einer Datei mit < kennt PowerShell dagegen nicht; dort schreibst du Get-Content text.txt | swift run wortzaehler --top 3.

Häufige Fehler

Das Programm wartet und tut nichts. Es liest von der Standardeingabe und wartet auf Text. Leite eine Datei mit < um (PowerShell: Get-Content datei.txt | davor) oder beende eine Eingabe von Hand mit Strg+D unter macOS und Linux.

main actor-isolated var '…' can not be mutated from a nonisolated context. Eine globale Variable wird aus einer Funktion heraus geändert. Kapsle den Zustand in einem Struct oder gib Werte zurück, statt global zu schreiben.

Übungen

  1. Ergänze die Statistik um die durchschnittliche Wortlänge, gerundet auf eine Nachkommastelle in der Ausgabe. Schreibe zuerst eine Prüfung.
  2. Ändere woerter(in:) so, dass Bindestriche innerhalb eines Wortes erhalten bleiben: „Swift-Code“ ist ein Wort, ein einzelner „-“ dagegen keins. Passe die Prüfungen an.
  3. Füge ein Argument --min LÄNGE hinzu, das nur Wörter ab dieser Länge zählt.
Lösungsweg zu Übung 2

Trenne zuerst an allem außer Buchstaben, Ziffern und Bindestrich, und entferne danach Bindestriche am Anfang und Ende jedes Teils. Teile, die danach leer sind, fallen weg:

loesung-2.swiftAusgeführt Swift 6.4.0, Linux x86-64, 19.10.2026
func woerterMitBindestrich(in text: String) -> [String] {
    text
        .split(whereSeparator: { !($0.isLetter || $0.isNumber || $0 == "-") })
        .map { teil in
            var wort = Substring(teil)
            while wort.first == "-" { wort.removeFirst() }
            while wort.last == "-" { wort.removeLast() }
            return wort.lowercased()
        }
        .filter { !$0.isEmpty }
}

print(woerterMitBindestrich(in: "Swift-Code ist - kurz gesagt - lesbar."))
print(woerterMitBindestrich(in: "--- Ende ---"))

Ausgabe

["swift-code", "ist", "kurz", "gesagt", "lesbar"]
["ende"]