Um an einen Swift-String angehängten Text zu verfolgen, speichere Offsets in einer benannten Kodierung, statt alte String-Indizes aufzubewahren. Diese Anleitung erstellt ein UTF-16-Segmentregister, das nur erweitert wird, für eine kleine lokale Mac-Textvorschau-Berechnung.

Die Aufgabe ist klar umrissen: synthetische Textfragmente verbinden, festhalten, an welcher Stelle jedes Fragment steht, und nach späteren Anhängungen seinen exakten Inhalt wiederherstellen. Das Beispiel läuft vollständig im Arbeitsspeicher und gibt seine Ergebnisse aus. Seine Segmentgrenzen beschreiben die Beiträge zum Text, die sich von den Grenzen der angezeigten Zeichen unterscheiden können.

Warum das Speichern des alten Endindex unzuverlässig ist

Ein naheliegender Ansatz speichert text.endIndex, hängt ein Fragment an und kombiniert den gespeicherten Index mit dem neuen text.endIndex. Dabei wird ein Index, der vor einer Mutation ermittelt wurde, mit einem String kombiniert, der erst danach vorliegt. Swifts Dokumentation zu Collections warnt ausdrücklich davor, dass mutierende Operationen gespeicherte Indizes ungültig machen können. Ein erfolgreiches Ergebnis mit einem kurzen Beispiel begründet keine allgemeine Garantie für spätere Mutationen. Siehe Apples Dokumentation zu Collections.

Ein weiteres Problem bleibt selbst dann, wenn du die Indizes neu erstellst: Durch das Anhängen von Text kann sich die Zeichengrenze an der Verbindungsstelle ändern. Swifts Sprachleitfaden veranschaulicht das mit "cafe", gefolgt von "\u{0301}", einem kombinierenden Akutakzent. Der resultierende Text enthält weiterhin vier Swift-Character-Werte, weil sich der Akzent mit dem vorangehenden Buchstaben verbindet. Siehe Strings und Zeichen.

Daher lässt sich mit der alten Zeichenanzahl nicht zuverlässig der Anfang eines angehängten Fragments im fertigen String finden, indem man in ihm um so viele Zeichen weitergeht. Im Akzentbeispiel beginnt der angehängte Beitrag innerhalb des endgültigen Graphemclusters. Es gibt kein eigenes angezeigtes Zeichen, das diesem Beitrag entspricht.

Betrachte eine Anhängungsgrenze und eine Zeichengrenze als getrennte Konzepte. Die erste hält fest, wie der Text zusammengesetzt wurde; die zweite beschreibt, wie Swift den zusammengesetzten Text für zeichenbasierte Operationen gruppiert.

Welche Koordinaten sollte das Register speichern?

Verwende für diese Mac-Berechnung ganzzahlige UTF-16-Offsets und -Längen. Apple dokumentiert, dass NSString seine Stringpositionen und -bereiche in UTF-16-Codeeinheiten ausdrückt. Swifts String.utf16-Ansicht stellt passende Codeeinheiten bereit, sodass das Register für die Messung und das Extrahieren von Teilstrings mit Foundation dasselbe Koordinatensystem verwenden kann. Siehe NSString und Swifts UTF-16-Ansicht von String.

Aufgabe Geeignete Darstellung Bedingung
Jedes ursprüngliche Fragment einzeln wiederherstellen Ein Array eigener String-Werte Koordinaten im kombinierten Text werden nicht benötigt
Exakte Beiträge im kombinierten Text lokalisieren UTF-16-Offsets und -Längen Frühere Textteile bleiben unverändert
Mit angezeigten Zeichen arbeiten Neue Zeichenindizes im aktuellen String Segmentgrenzen müssen als Graphemgrenzen neu betrachtet werden
Positionen bei beliebigen Bearbeitungen erhalten Ein bearbeitungsbewusstes Positionsmodell Dieses Register für reine Anhängungen reicht nicht aus

Das folgende Register hat vier ausdrücklich festgelegte Regeln:

  1. Es beginnt mit einem leeren String.
  2. Seine einzige Textmutation ist das Anhängen eines vollständigen Swift-String.
  3. Bei jeder Anhängung werden die bisherige UTF-16-Gesamtlänge und die UTF-16-Länge des Fragments gespeichert.
  4. Eine Segmentnummer ist nur zusammen mit der Registerinstanz oder dem Snapshot aussagekräftig, aus der beziehungsweise dem sie stammt.

Diese Regeln machen die Offsets brauchbar. Beim Anhängen eines Suffixes bleiben die zuvor kodierten Inhalte an ihrer Stelle, sodass frühere Spannen ihre Positionen behalten. Das Einfügen, Entfernen, Ersetzen oder Umwandeln früherer Textteile würde einen anderen Vertrag erfordern. Dieses Design leitet sich aus den dokumentierten Stringdarstellungen ab und ist keine von Swift bereitgestellte automatische Funktion zur Positionsverfolgung.

Ein vollständiges lokales Segmentregister erstellen

Verwende ein macOS-Swift-Kommandozeilenziel mit verfügbarer Foundation-Bibliothek. Ersetze das oberste Beispielprogramm durch das vollständige Programm unten. Die Implementierung verwendet append(contentsOf:), dessen dokumentierte Swift-Signatur einen weiteren String akzeptiert. Siehe Apples Referenz zu append(contentsOf:).

import Foundation

struct Segment: Sendable {
    let utf16Location: Int
    let utf16Length: Int

    var foundationRange: NSRange {
        NSRange(location: utf16Location, length: utf16Length)
    }
}

enum LedgerError: Error {
    case unknownSegment(Int)
}

struct SegmentLedger: Sendable {
    private(set) var text = ""
    private(set) var segments: [Segment] = []
    private var utf16Total = 0

    @discardableResult
    mutating func append(_ fragment: String) -> Int {
        let length = fragment.utf16.count
        let (newTotal, overflow) =
            utf16Total.addingReportingOverflow(length)
        precondition(!overflow, "UTF-16 total exceeds Int capacity")

        let segment = Segment(
            utf16Location: utf16Total,
            utf16Length: length
        )

        text.append(contentsOf: fragment)
        segments.append(segment)
        utf16Total = newTotal
        return segments.count - 1
    }

    func fragment(at segmentID: Int) throws -> String {
        guard segments.indices.contains(segmentID) else {
            throw LedgerError.unknownSegment(segmentID)
        }

        let segment = segments[segmentID]
        if segment.utf16Length == 0 {
            return ""
        }

        return (text as NSString).substring(
            with: segment.foundationRange
        )
    }
}

let inputs = ["cafe", "\u{0301}", "", "\u{1F7E6}", " done"]
var ledger = SegmentLedger()

for input in inputs {
    ledger.append(input)
}

let expectedLocations = [0, 4, 5, 5, 7]
let expectedLengths = [4, 1, 0, 2, 5]

for id in inputs.indices {
    let recovered = try ledger.fragment(at: id)
    let segment = ledger.segments[id]

    // Compare code units, so the check is encoding-exact.
    precondition(Array(recovered.utf16) == Array(inputs[id].utf16))
    precondition(segment.utf16Location == expectedLocations[id])
    precondition(segment.utf16Length == expectedLengths[id])

    print("\(id): location=\(segment.utf16Location), " +
          "length=\(segment.utf16Length)")
}

precondition(ledger.text.utf16.count == 12)
precondition(ledger.text.count == 10)

// Later appends must preserve earlier segment contents.
ledger.append("!")
for id in inputs.indices {
    let recovered = try ledger.fragment(at: id)
    precondition(Array(recovered.utf16) == Array(inputs[id].utf16))
}

// Invalid segment numbers produce an explicit error.
do {
    _ = try ledger.fragment(at: -1)
    preconditionFailure("Expected an unknown-segment error")
} catch LedgerError.unknownSegment(let id) {
    precondition(id == -1)
}

print("All segment checks passed.")

Dieses Programm wurde anhand der abgerufenen API-Dokumentation geprüft. Hier wurde es nicht ausgeführt, weil in der verfügbaren Shell kein Swift-Programm liegt. Die enthaltenen Vorbedingungen machen das erwartete Verhalten des Beispiels deutlich, wenn du es auf deinem Mac ausführst.

Was das Beispiel prüfen soll

Die erwarteten Segmentkoordinaten werden anhand der literalen Eingaben berechnet. Der kombinierende Akzent trägt eine UTF-16-Codeeinheit bei, das leere Fragment null und das blaue Quadrat zwei. Das abschließende Suffix umfasst fünf. Dies sind beispielhafte Berechnungen der Eingaben, keine Leistungsmessungen.

Segment Eingabe UTF-16-Position UTF-16-Länge
0 "cafe" 0 4
1 "\u{0301}" 4 1
2 "" 5 0
3 "\u{1F7E6}" 5 2
4 " done" 7 5

Das leere Fragment erhält eine eigene Segmentnummer, obwohl es dieselbe Position wie das folgende Segment hat. So bleibt der Verlauf der Anhängungen erhalten, ohne Text hinzuzuerfinden. Die explizite Rückgabe von "" vermeidet außerdem in diesem Fall einen Foundation-Aufruf zum Extrahieren eines Teilstrings.

Die Wiederherstellungsprüfung vergleicht absichtlich UTF-16-Arrays. Beim Stringvergleich erkennt Swift kanonische Äquivalenz: Manche Strings mit unterschiedlichen zugrunde liegenden Skalarfolgen gelten als gleich. Eine kodierungsexakte Assertion passt besser zum Vertrag dieses Registers. Siehe die Erläuterungen zum Stringvergleich unter Strings und Zeichen.

Warum die Extraktion Foundation verwendet

Die gespeicherten Ganzzahlen werden in einen NSRange umgewandelt und direkt an NSString.substring(with:) übergeben. Apple dokumentiert, dass diese Methode den angegebenen Bereich extrahiert und voraussetzt, dass dieser innerhalb der Grenzen des Empfängers liegt. Ungültige Bereiche können eine Exception auslösen. Das Register kontrolliert sowohl den Text als auch seine Spannen und weist unbekannte Segmentnummern vor der Extraktion zurück. Siehe substring(with:).

Jede gespeicherte Grenze stammt vom Anhängen eines vollständigen Swift-Strings. Das Beispiel erzeugt nie selbst einen Offset mitten im UTF-16-Surrogatpaar des blauen Quadrats. Wie der Akzent zeigt, kann eine Grenze dennoch innerhalb einer zusammengesetzten Zeichenfolge liegen. Genau angehängte Beiträge wiederherzustellen ist der Zweck dieser Darstellung.

Verwende String.count nicht, um diese Foundation-Bereiche zu erstellen. Apple definiert NSString.length als Anzahl der UTF-16-Codeeinheiten. Das ist ein anderes Maß als die Zeichenanzahl und sagt nichts über die dargestellte Breite aus. Siehe NSString.length.

Mutation, Segmenterstellung und Snapshots zusammenhalten

Für eine kleine synchrone Berechnung reicht der Werttyp aus: Ein Besitzer hängt Fragmente an und liest das Ergebnis. Seine privaten Setter verhindern, dass Aufrufer den kombinierten Text unabhängig von der Segmenttabelle ersetzen. Die zwischengespeicherte UTF-16-Gesamtlänge vermeidet außerdem, den wachsenden kombinierten String bei jeder Anhängung erneut vollständig zu zählen.

Wenn mehrere asynchrone Tasks zu einem Register beitragen müssen, lege es in einen einzelnen Actor oder belasse es auf @MainActor, wenn es zu einer Mac-Oberfläche gehört. Führe die Anhängung und die Aktualisierung der Metadaten gemeinsam in einer synchronen isolierten Methode aus. Swift dokumentiert, dass synchroner, actor-isolierter Code an einem Aufhängepunkt innerhalb dieser Methode nicht unterbrochen werden kann. Siehe Nebenläufigkeit.

Gib Text und Segmentmetadaten gemeinsam als einen Wert-Snapshot zurück. Lies Text und Segmenttabelle nicht in getrennten await-Aufrufen: Zwischen diesen Aufrufen könnte eine weitere Anhängung erfolgen. Die Sendable-Deklarationen des Beispiels beschreiben Werte, die Isolationsebenen überschreiten können; sie serialisieren keine gleichzeitigen Schreibzugriffe auf eine gemeinsame Variable. Swifts Sendable-Dokumentation erklärt den Unterschied zwischen übertragbaren Werten und geschütztem veränderlichem Zustand.

Wenn die Reihenfolge der Beiträge wichtig ist, lege sie vor dem Anhängen fest. Ein Actor schützt die interne Konsistenz des Registers; die Anwendung muss weiterhin bestimmen, welches Fragment zuerst kommt.

Erkennen, wann diese Offsets die Aufgabe nicht mehr erfüllen

Gehe diese Prüfschritte durch, bevor du eine Spanne erneut verwendest:

  1. Besitz prüfen. Gehört die Segmentnummer zu diesem Register oder zu einem kompatiblen, nur erweiterten Nachfolger?
  2. Mutationsverlauf prüfen. Hat sich vor der gespeicherten Spanne etwas geändert? Falls ja, erstelle das Register neu oder verwende ein bearbeitungsbewusstes Modell.
  3. Einheiten prüfen. Verwendet der Aufrufer UTF-16-Codeeinheiten als Koordinaten? Ersetze sie nicht durch Zeichen- oder UTF-8-Byte-Anzahlen.
  4. Beabsichtigtes Ergebnis prüfen. Stellst du den exakt angehängten Beitrag wieder her oder wählst du ein ganzes angezeigtes Zeichen aus? Dafür können unterschiedliche Grenzen nötig sein.

Eine zeichenorientierte Auswahl kann nicht immer nur einen einzelnen angehängten Beitrag darstellen. Der Akzent gehört zum selben angezeigten Zeichen wie der vorangehende Buchstabe. Wenn es auf dem Mac darum geht, Text hervorzuheben, lege eine klare Darstellungsregel fest: Erweitere die Auswahl auf das umgebende Zeichen oder zeige den Beitrag separat in einer Segmentliste an. Die Erweiterung schließt Text aus einem früheren Segment ein; bewahre deshalb die ursprünglichen Offsets für die exakte Wiederherstellung auf.

Für die exakte Nachverfolgung von Anhängungen gilt eine einfache Invariante: Lass den Präfix unverändert, benenne die Koordinateneinheiten und speichere jede Spanne zusammen mit ihrem Text. Unter diesem Vertrag kann das Register frühere Beiträge nach späteren Anhängungen wiederherstellen, ohne einen einzelnen String.Index über eine Mutation hinweg mitzuführen.

Quellen