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.
Auf dieser Seite
Was du baust
Am Ende rufst du das Programm so auf und bekommst eine kleine Statistik:
swift run wortzaehler --top 3 < text.txt
Der Ablauf hat drei Teile, und genau so ist auch der Code aufgeteilt:
- Einlesen: Text Zeile für Zeile von der Standardeingabe lesen, das Argument
--topauswerten. - Auswerten: Wörter finden, zählen, sortieren. Das ist reine Logik ohne Ein- und Ausgabe.
- 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
mkdir wortzaehler
cd wortzaehler
swift package init --name wortzaehler --type executable
Ersetze danach die erzeugte Package.swift durch diese Fassung mit einem Testziel:
// 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:
wortzaehler/
├── Package.swift
├── Sources/
│ └── wortzaehler/
│ ├── main.swift
│ └── Wortstatistik.swift
└── Tests/
└── wortzaehlerTests/
└── WortstatistikTests.swift
Die Logik: Wortstatistik.swift
/// 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.isLetterkennt 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.
Wortstatistikist ein Struct mitlet-Eigenschaften. Einmal berechnet, ändert sich nichts mehr.Equatableerlaubt, 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
/// 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:
// 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:
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
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
- Ergänze die Statistik um die durchschnittliche Wortlänge, gerundet auf eine Nachkommastelle in der Ausgabe. Schreibe zuerst eine Prüfung.
- Ä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. - Füge ein Argument
--min LÄNGEhinzu, 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:
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"]