fmt.ScanState ist der Eingabe-Kontext, den die fmt-Lesefunktionen (Scan, Sscan, Fscan und ihre Varianten) an die Scan-Methode eines Scanner übergeben. Wer einen eigenen Scanner schreibt, sieht den Eingabestream nicht direkt als string oder io.Reader, sondern ausschließlich durch die Linse dieses Interfaces — und das ist Absicht. ScanState kapselt rune-weisen Zugriff, Lookahead durch Unread, Whitespace-Logik und Width-Limits in einem einzigen Wert.
Das Interface bettet io.RuneScanner ein und ergänzt es um genau die Stücke, die ein fmt-Scanner zusätzlich braucht: Token zum Lesen ganzer Wörter mit benutzerdefiniertem Trennzeichen-Filter, SkipSpace für die Whitespace-Disziplin und Width für die im Verb angegebene Längenbegrenzung. Dadurch bleibt der Scanner-Code kurz, ohne dass eigenes Buffer-Handling nötig ist.
Interface-Methoden
Der komplette Methodensatz von ScanState ist überschaubar und passt auf eine Bildschirmseite. Wichtig ist, dass ReadRune und UnreadRune nicht eigene Deklarationen sind, sondern aus dem eingebetteten io.RuneScanner stammen — das macht jeden ScanState automatisch auch zu einem io.RuneScanner und damit zu einem io.Reader-Verwandten auf Rune-Ebene.
type ScanState interface {
// Aus io.RuneScanner eingebettet:
ReadRune() (r rune, size int, err error)
UnreadRune() error
// Liest bis f(r) == false; skipSpace überspringt führende Whitespaces.
Token(skipSpace bool, f func(rune) bool) (token []byte, err error)
// Überspringt Whitespace gemäß der Whitespace-Regel des aktuellen Verbs.
SkipSpace()
// Width liefert das %Nd-Limit; ok=false, wenn keines gesetzt war.
Width() (wid int, ok bool)
// io.Reader-Implementierung; selten direkt nötig.
Read(buf []byte) (n int, err error)
}Diese sechs Methoden bilden eine kleine Hierarchie: ganz unten der rune-weise Zugriff, in der Mitte Token als Bulk-Reader mit Filter, oben Hilfsmethoden für Whitespace und Width. Die meisten Scanner kommen mit Token plus SkipSpace aus; ReadRune/UnreadRune werden gebraucht, wenn Lookahead nötig ist.
ReadRune und UnreadRune — die niedrigste Ebene
ReadRune zieht das nächste UTF-8-Zeichen aus dem Stream und liefert es zusammen mit seiner Byte-Größe. UnreadRune legt genau die zuletzt gelesene Rune wieder zurück, sodass ein anschließendes ReadRune sie erneut liefert. Das ist das klassische Lookahead-Pattern: lese eine Rune, prüfe sie, lege sie zurück, wenn sie nicht zu dir gehört.
// peekRune liest die nächste Rune, ohne den Stream zu verbrauchen.
func peekRune(st fmt.ScanState) (rune, error) {
r, _, err := st.ReadRune()
if err != nil {
return 0, err
}
if err := st.UnreadRune(); err != nil {
return 0, err
}
return r, nil
}Mehr als eine Rune Lookahead garantiert das Interface nicht — UnreadRune darf laut Vertrag nur die letzte Rune zurücklegen. Wer mehr braucht, muss selbst puffern oder die Logik so umstellen, dass eine Rune reicht.
Token — Bulk-Lesen mit Filter
Token ist der Arbeitspferd-Aufruf für die meisten Scanner. Er liest Bytes aus dem Stream, solange die Filter-Funktion f für die jeweilige Rune true zurückgibt, und liefert das Ergebnis als []byte. Mit skipSpace == true überspringt er vorher führende Whitespaces — praktisch identisch zu dem, was fmt.Scan für eingebaute Verben tut.
package main
import (
"fmt"
"unicode"
)
type Word string
func (w *Word) Scan(st fmt.ScanState, verb rune) error {
tok, err := st.Token(true, func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
})
if err != nil {
return err
}
*w = Word(tok)
return nil
}
func main() {
var a, b Word
fmt.Sscan("hallo, welt", &a, &b)
fmt.Printf("a=%q b=%q\n", a, b)
}a="hallo" b="welt"Die Filter-Funktion entscheidet, was als Token-Bestandteil zählt. Sobald sie false liefert, hört Token auf — das stoppende Zeichen bleibt im Stream und kann vom nächsten Scanner gelesen werden. Genau dieses Verhalten macht es möglich, mehrere Scanner-Werte hintereinander an einen einzigen Sscan-Aufruf zu hängen.
SkipSpace — Whitespace mit Verb-Bewusstsein
SkipSpace überspringt Whitespace nach den Regeln des gerade laufenden Scan-Vorgangs. Wichtig: die Regel hängt vom verwendeten Verb ab. Bei %v, %d und Verwandten gehört auch Newline als Trenner; bei %c dagegen nicht — SkipSpace weiß das selbst und verhält sich passend zum Kontext, in dem es aufgerufen wird.
In der Praxis ruft man SkipSpace selten direkt auf, weil Token(true, …) es bereits intern erledigt. Manuelle Aufrufe lohnen sich, wenn ein Scanner mehrere Teil-Tokens nacheinander liest und dazwischen Whitespace tolerieren will, ohne ein neues Token mit skipSpace=true zu starten.
Width — das %Nd-Limit
Width liefert das im Verb angegebene Längenlimit als Tupel (wid int, ok bool). Steht im Format-String etwa %5d, dann ist wid == 5, ok == true; ohne explizite Width ist ok == false. Entscheidend ist, dass fmt dieses Limit nicht automatisch durchsetzt — es ist eine Information, kein Zwang. Ein sauberer Scanner respektiert sie selbst.
// readUpToWidth liest höchstens so viele Runen, wie Width() vorgibt.
func readUpToWidth(st fmt.ScanState, isPart func(rune) bool) ([]rune, error) {
wid, ok := st.Width()
var out []rune
for {
if ok && len(out) >= wid {
return out, nil
}
r, _, err := st.ReadRune()
if err != nil {
return out, err
}
if !isPart(r) {
_ = st.UnreadRune()
return out, nil
}
out = append(out, r)
}
}Wer die Width ignoriert, läuft Gefahr, dass das eigene Verb sich anders verhält als die eingebauten — gerade bei festen Spaltenbreiten in tabellarischer Eingabe ist das schnell ein subtiler Bug.
Read — io.Reader-Fallback
Read macht den ScanState zu einem io.Reader für Bulk-Bytes. Das ist nützlich, wenn ein Scanner einen festen Byte-Block einliest (etwa eine binäre Header-Struktur), reicht im Alltag aber selten, weil Token die typischen Fälle schon abdeckt. Wer Read nutzt, sollte daran denken, dass das Lesen rohe Bytes ohne UTF-8-Beachtung liefert — Whitespace-Logik und Width-Limits greifen hier nicht.
Praxis 1 — Komma-getrennte Werte
Ein realistisches Szenario: ein Scanner für CSV-artige Felder, der bis zum nächsten Komma liest. Mit einer kleinen Filter-Funktion bei Token ist die Implementierung kurz und nutzt das Interface so, wie es gedacht ist.
package main
import "fmt"
type CSVField string
func (f *CSVField) Scan(st fmt.ScanState, verb rune) error {
// Führende Whitespaces überspringen, dann bis zum Komma lesen.
tok, err := st.Token(true, func(r rune) bool {
return r != ','
})
if err != nil {
return err
}
*f = CSVField(tok)
// Das stoppende Komma konsumieren, falls vorhanden.
r, _, err := st.ReadRune()
if err == nil && r != ',' {
_ = st.UnreadRune()
}
return nil
}
func main() {
var a, b, c CSVField
fmt.Sscan("hallo,welt,rest", &a, &b, &c)
fmt.Printf("a=%q b=%q c=%q\n", a, b, c)
}a="hallo" b="welt" c="rest"Der Filter r != ',' ist die ganze Trenner-Logik. Anschließend wird das stoppende Komma manuell konsumiert, damit der nächste Scan-Aufruf nicht direkt darauf trifft. Dieses Muster — Token lesen, Separator konsumieren — taucht in fast jedem nicht-trivialen Scanner wieder auf.
Praxis 2 — Feste Feldbreite respektieren
Wenn ein Verb mit Width-Angabe wie %5d benutzt wird, kann der Scanner das Limit aus Width() herausziehen und selbst durchsetzen. Damit verhält sich ein eigener Typ konsistent zu den eingebauten — fünf Zeichen heißen fünf Zeichen, egal ob int oder MyField.
package main
import (
"fmt"
"unicode"
)
type FixedField string
func (f *FixedField) Scan(st fmt.ScanState, verb rune) error {
st.SkipSpace()
wid, hasWidth := st.Width()
var buf []rune
for {
if hasWidth && len(buf) >= wid {
break
}
r, _, err := st.ReadRune()
if err != nil {
break
}
if unicode.IsSpace(r) {
_ = st.UnreadRune()
break
}
buf = append(buf, r)
}
*f = FixedField(buf)
return nil
}
func main() {
var a, b FixedField
fmt.Sscanf("abcdefgh xyz", "%5s%s", &a, &b)
fmt.Printf("a=%q b=%q\n", a, b)
}a="abcde" b="fgh"Hinweis am Rand: das Verb %s triggert den eingebauten String-Scanner, nicht den Scanner-Pfad — für einen echten Test der eigenen Scan-Methode müsste man einen Custom-Verb-Pfad mit %v und Width nutzen. Das Muster im Code ist trotzdem das, was man in der eigenen Scanner-Implementierung schreibt.
Embedded io.RuneScanner
ScanState bettet io.RuneScanner ein — jeder ScanState ist also automatisch ein RuneScanner und akzeptiert die Standardmethoden ReadRune/UnreadRune ohne Extra-Adapter.
Token mit Filter-Funktion
Token(skipSpace, f) ist der Bulk-Reader: liest, solange f(r) == true, und stoppt sauber am ersten ablehnenden Zeichen. Das stoppende Zeichen bleibt im Stream.
SkipSpace ist verb-abhängig
Was als Whitespace zählt, hängt vom aktuell verarbeiteten Verb ab. %c etwa skippt nichts, %v und %d skippen Newline mit. SkipSpace kennt diesen Kontext selbst.
Width ist Hinweis, kein Zwang
Width() liefert das %Nd-Limit als Information — fmt erzwingt es nicht. Ein sauberer Scanner respektiert es selbst, sonst weicht das Verhalten von den eingebauten Verben ab.
UnreadRune für Ein-Rune-Lookahead
Garantiert ist nur das Zurücklegen der zuletzt gelesenen Rune. Wer mehr Lookahead braucht, muss selbst puffern.
EOF kommt als io.EOF
Am Ende des Streams liefern ReadRune und Token io.EOF als Error — Scanner sollten diesen Wert sauber durchreichen, damit höhere Schichten den Stream-Abschluss erkennen.
Nur im Scanner-Kontext sichtbar
ScanState taucht ausschließlich als Parameter in Scan(st ScanState, verb rune) error auf. Es gibt keinen Konstruktor — fmt baut den Wert intern und reicht ihn an deine Methode weiter.
Schlanker als bufio.Scanner
Die API ist mit Absicht klein: keine konfigurierbaren Split-Funktionen, keine Buffer-Größen — Token mit Filter deckt fast alles ab, was ein fmt-Scanner braucht.