Kapitel 4. Kopfweh

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Steh zur Abwechslung mal auf deinem eigenen Kopf / Gib mir etwas Haut, die ich mein Eigen nennen kann

They Might Be Giants, "Stand on Your Own Head" (1988)

Die Herausforderung in diesem Kapitel besteht darin, das Programm head zu implementieren, das die ersten paar Zeilen oder Bytes einer oder mehrerer Dateien ausgibt.ist eine gute Möglichkeit, den Inhalt einer regulären Textdatei zu betrachten und oft eine viel bessere Wahl als cat. Wenn du mit einem Verzeichnis konfrontiert wirst, das z. B. die Ausgabedateien eines Prozesses enthält, kann head dir helfen, schnell nach möglichen Problemen zu suchen. Es ist besonders nützlich, wenn du mit extrem großen Dateien zu tun hast, da es nur die ersten paar Bytes oder Zeilen einer Datei liest (im Gegensatz zu cat, das immer die gesamte Datei liest).

In diesem Kapitel erfährst du, wie du Folgendes tun kannst:

  • Erstelle optionale Befehlszeilenargumente, die numerische Werte akzeptieren

  • Zwischen Typen konvertieren mit as

  • take für einen Iterator oder einen Filehandle verwenden

  • Zeilenenden beim Lesen eines Filehandles beibehalten

  • Bytes versus Zeichen aus einem Filehandle lesen

  • Verwende den Turbofish-Operator

Wie der Kopf funktioniert

Ich beginne mit einem Überblick über head, damit du weißt, was von deinem Programm erwartet wird.Es gibt viele Implementierungen des ursprünglichen AT&T Unix-Betriebssystems, wie Berkeley Standard Distribution (BSD), SunOS/Solaris, HP-UX und Linux. Die meisten dieser Betriebssysteme haben eine Version des Programms head, das standardmäßig die ersten 10 Zeilen einer oder mehrerer Dateien anzeigt. Die meisten haben wahrscheinlich die Optionen -n, um die Anzahl der angezeigten Zeilen zu steuern, und -c, um stattdessen eine bestimmte Anzahl von Bytes anzuzeigen. Die BSD-Version hat nur diese beiden Optionen, die ich über man head:

HEAD(1)                   BSD General Commands Manual                  HEAD(1)

NAME
     head -- display first lines of a file

SYNOPSIS
     head [-n count | -c bytes] [file ...]

DESCRIPTION
     This filter displays the first count lines or bytes of each of the speci-
     fied files, or of the standard input if no files are specified.  If count
     is omitted it defaults to 10.

     If more than a single file is specified, each file is preceded by a
     header consisting of the string ''==> XXX <=='' where ''XXX'' is the name
     of the file.

EXIT STATUS
     The head utility exits 0 on success, and >0 if an error occurs.

SEE ALSO
     tail(1)

HISTORY
     The head command appeared in PWB UNIX.

BSD                              June 6, 1993                              BSD

Mit der GNU Version kann ich head --help ausführen, um die Verwendung zu lesen:

Usage: head [OPTION]... [FILE]...
Print the first 10 lines of each FILE to standard output.
With more than one FILE, precede each with a header giving the file name.
With no FILE, or when FILE is -, read standard input.

Mandatory arguments to long options are mandatory for short options too.
  -c, --bytes=[-]K         print the first K bytes of each file;
                             with the leading '-', print all but the last
                             K bytes of each file
  -n, --lines=[-]K         print the first K lines instead of the first 10;
                             with the leading '-', print all but the last
                             K lines of each file
  -q, --quiet, --silent    never print headers giving file names
  -v, --verbose            always print headers giving file names
      --help     display this help and exit
      --version  output version information and exit

K may have a multiplier suffix:
b 512, kB 1000, K 1024, MB 1000*1000, M 1024*1024,
GB 1000*1000*1000, G 1024*1024*1024, and so on for T, P, E, Z, Y.

Beachte, dass die GNU-Version negative Zahlen für -n und -c und mit Suffixen wie K, M, usw. angeben kann, die das Challenge-Programm nicht implementiert. Sowohl in der BSD- als auch in der GNU-Version sind die Dateien optionale Positionsargumente, die standardmäßig STDIN lesen oder wenn ein Dateiname ein Bindestrich ist.

Um zu zeigen, wie head funktioniert, verwende ich die Dateien in 04_headr/tests/inputs:

  • empty.txt: eine leere Datei

  • one.txt: eine Datei mit einer Textzeile

  • two.txt: eine Datei mit zwei Textzeilen

  • three.txt: eine Datei mit drei Textzeilen und Windows-Zeilenenden

  • twelve.txt: eine Datei mit 12 Textzeilen

Bei einer leeren Datei gibt es keine Ausgabe, was du mit dem folgenden Befehl überprüfen kannst head tests/inputs/empty.txtWie bereits erwähnt, gibt head standardmäßig die ersten 10 Zeilen einer Datei aus:

$ head tests/inputs/twelve.txt
one
two
three
four
five
six
seven
eight
nine
ten

Mit der Option -n kannst du die Anzahl der angezeigten Zeilen festlegen. Mit dem folgenden Befehl kann ich zum Beispiel festlegen, dass nur die ersten beiden Zeilen angezeigt werden:

$ head -n 2 tests/inputs/twelve.txt
one
two

Die Option -c zeigt nur die angegebene Anzahl von Bytes aus einer Datei an. Ich kann zum Beispiel nur die ersten beiden Bytes anzeigen:

$ head -c 2 tests/inputs/twelve.txt
on

Seltsamerweise erlaubt es die GNU-Version, sowohl -n als auch -c anzugeben und zeigt standardmäßig Bytes an. Die BSD-Version weist beide Argumente zurück:

$ head -n 1 -c 2 tests/inputs/one.txt
head: can't combine line and byte counts

Jeder Wert für -n oder -c, der keine positive ganze Zahl ist, führt zu einem Fehler, der das Programm anhält, und die Fehlermeldung enthält den illegalen Wert:

$ head -n 0 tests/inputs/one.txt
head: illegal line count -- 0
$ head -c foo tests/inputs/one.txt
head: illegal byte count -- foo

Wenn es mehrere Argumente gibt, fügt head eine Kopfzeile und eine Leerzeile zwischen den einzelnen Dateien ein. Beachte in der folgenden Ausgabe, dass das erste Zeichen in tests/inputs/one.txt ein Ö ist, ein dummes Multibyte-Zeichen, das ich eingefügt habe, um das Programm zu zwingen, zwischen Bytes und Zeichen zu unterscheiden:

$ head -n 1 tests/inputs/*.txt
==> tests/inputs/empty.txt <==

==> tests/inputs/one.txt <==
Öne line, four words.

==> tests/inputs/three.txt <==
Three

==> tests/inputs/twelve.txt <==
one

==> tests/inputs/two.txt <==
Two lines.

Wenn keine Dateiargumente vorhanden sind, liest head von STDIN:

$ cat tests/inputs/twelve.txt | head -n 2
one
two

Wie bei cat in Kapitel 3 wird jede nicht existierende oder unlesbare Datei übersprungen und eine Warnung auf STDERR ausgegeben. Im folgenden Befehl verwende ich blargh als nicht existierende Datei und erstelle eine unlesbare Datei namens cant-touch-this:

$ touch cant-touch-this && chmod 000 cant-touch-this
$ head blargh cant-touch-this tests/inputs/one.txt
head: blargh: No such file or directory
head: cant-touch-this: Permission denied
==> tests/inputs/one.txt <==
Öne line, four words.

Mehr muss das Challenge-Programm dieses Kapitels nicht umsetzen.

Erste Schritte

Du hast vielleicht schon geahnt, dass das Programm, das du schreiben sollst, headr (sprich: head-er) heißen wird.Beginne mit dem Aufruf cargo new headrund füge dann die folgenden Abhängigkeiten zu deiner Cargo.toml hinzu:

[dependencies]
anyhow = "1.0.79"
clap = { version = "4.5.0", features = ["derive"] }

[dev-dependencies]
assert_cmd = "2.0.13"
predicates = "3.0.4"
pretty_assertions = "1.4.0"
rand = "0.8.5"

Kopiere mein 04_headr/tests-Verzeichnis in dein Projektverzeichnis und führe dann den Test aus. cargo testAlle Tests sollten fehlschlagen. Deine Aufgabe, wenn du sie annimmst, ist es, ein Programm zu schreiben, das diese Tests besteht. Ich schlage vor, dass du src/main.rs mit dem folgenden Code beginnst, um die drei Parameter des Programms mit einer Args Struktur darzustellen:

#[derive(Debug)]
struct Args {
    files: Vec<String>, 1
    lines: u64, 2
    bytes: Option<u64>, 3
}
1

files ist ein Vektor von Zeichenketten.

2

Die Anzahl der zu druckenden lines ist von der Art u64.

3

bytes wird eine optionale u64 sein.

Tipp

Alle Befehlszeilenargumente für dieses Programm sind optional, da files standardmäßig mit einem Bindestrich (-), lines standardmäßig mit 10 und bytes weggelassen werden können.

Die primitive u64 ist eine vorzeichenlose Ganzzahl, die 8 Byte Speicherplatz benötigt und usize ähnelt. ist eine vorzeichenlose Ganzzahl in Zeigergröße, deren Größe zwischen 4 Byte auf einem 32-Bit-Betriebssystem und 8 Byte auf einem 64-Bit-System variiert. Rust hat auch einen Typ isize, der eine vorzeichenbehaftete Ganzzahl in Zeigergröße ist, die du benötigst, um negative Zahlen darzustellen, wie es die GNU-Version tut. Da du nur positive Zahlen à la der BSD-Version speichern willst, kannst du bei einem vorzeichenlosen Typ bleiben. Beachte die anderen Rust-Typen u32/i32 (vorzeichenlose/vorzeichenbehaftete 32-Bit-Ganzzahl) und u64/i64 (vorzeichenlose/vorzeichenbehaftete 64-Bit-Ganzzahl), wenn du eine genauere Kontrolle darüber haben willst, wie groß diese Werte sein können.

Die Parameter lines und bytes werden in Funktionen verwendet, die die Typen usize und u64 erwarten, daher werden wir später besprechen, wie man zwischen diesen Typen konvertiert. Dein Programm sollte 10 als Standardwert für lines verwenden, aber bytes wird ein OptionDas bedeutet, dass bytes entweder Some<u64> ist, wenn der Benutzer einen gültigen Wert angibt, oder None, wenn er keinen Wert angibt.

Ich fordere dich auf, die Kommandozeilenargumente in dieser Struktur zu parsen, wie du willst. Um das Derive-Muster zu verwenden, kommentiere die vorangehende Args entsprechend.Wenn du lieber dem Builder-Muster folgst, solltest du eine get_args Funktion mit der folgenden Gliederung schreiben:

fn get_args() -> Args {
    let matches = Command::new("headr")
        .version("0.1.0")
        .author("Ken Youens-Clark <kyclark@gmail.com>")
        .about("Rust version of `head`")
        // What goes here?
        .get_matches();

    Args {
        files: ...
        lines: ...
        bytes: ...
    }
}

Aktualisiere main, um die Argumente zu parsen und auszudrucken:

fn main() {
    let args = Args::parse();
    println!("{:#?}", args);
}

Schau, ob du dein Programm dazu bringen kannst, eine Verwendung wie die folgende zu drucken.Beachte, dass ich die kurzen und langen Namen aus der GNU-Version verwende:

$ cargo run -- -h
Rust version of `head`

Usage: headr [OPTIONS] [FILE]...

Arguments:
  [FILE]...  Input file(s) [default: -]

Options:
  -n, --lines <LINES>  Number of lines [default: 10]
  -c, --bytes <BYTES>  Number of bytes
  -h, --help           Print help
  -V, --version        Print version

Führe das Programm ohne Eingaben aus und überprüfe, ob die Standardwerte richtig eingestellt sind:

$ cargo run
Args {
    files: [
        "-", 1
    ],
    lines: 10, 2
    bytes: None, 3
}
1

files sollte standardmäßig einen Bindestrich (-) als Dateinamen enthalten.

2

Die Nummer von lines sollte standardmäßig 10 lauten.

3

bytes sollte None sein.

Führe nun das Programm mit Argumenten aus und stelle sicher, dass sie richtig geparst werden:

$ cargo run -- -n 3 tests/inputs/one.txt
Args {
    files: [
        "tests/inputs/one.txt", 1
    ],
    lines: 3, 2
    bytes: None, 3
}
1

Das Positionsargument tests/inputs/one.txt wird als eines der files geparst.

2

Die Option -n für lines setzt dies auf 3.

3

Die Option -b für bytes ist standardmäßig auf None eingestellt.

Wenn ich mehr als ein Positionsargument angebe, werden sie alle in files gespeichert, und das -c -Argument wird in bytes gespeichert. Im folgenden Befehl verlasse ich mich wieder auf die bash Shell, um die Datei glob *.txt in alle Dateien mit der Endung .txt zu expandieren. PowerShell-Benutzer sollten sich die entsprechende Verwendung von Get-ChildItem ansehen, die im Abschnitt "Iterieren durch die Datei-Argumente" gezeigt wird :

$ cargo run -- -c 4 tests/inputs/*.txt
Args {
    files: [
        "tests/inputs/empty.txt", 1
        "tests/inputs/one.txt",
        "tests/inputs/three.txt",
        "tests/inputs/twelve.txt",
        "tests/inputs/two.txt",
    ],
    lines: 10, 2
    bytes: Some( 3
        4,
    ),
}
1

Es gibt vier Dateien, die auf .txt enden.

2

lines ist immer noch auf den Standardwert 10 eingestellt.

3

Die -c 4 führt dazu, dass die bytes nun Some(4) ist.

Jeder Wert für -n oder -c, der nicht in eine positive ganze Zahl umgewandelt werden kann, sollte dazu führen, dass das Programm mit einem Fehler abbricht.Verwende clap::value_parser um sicherzustellen, dass die ganzzahligen Argumente gültig sind, und wandle sie in Zahlen um:

$ cargo run -- -n blargh tests/inputs/one.txt
error: invalid value 'blargh' for '--lines <LINES>':
invalid digit found in string
$ cargo run -- -c 0 tests/inputs/one.txt
error: invalid value '0' for '--bytes <BYTES>':
0 is not in 1..18446744073709551615

Das Programm sollte sowohl die Verwendung von -n als auch von -c verbieten:

$ cargo run -- -n 1 -c 1 tests/inputs/one.txt
error: the argument '--lines <LINES>' cannot be used with '--bytes <BYTES>'

Usage: headr --lines <LINES> <FILE>...
Hinweis

Allein das Parsen und Validieren der Argumente ist eine Herausforderung, aber ich weiß, dass du es schaffen kannst. Hör hier auf zu lesen und bring dein Programm dazu, alle Tests zu bestehen, die mit cargo test dies:

running 3 tests
test dies_bad_lines ... ok
test dies_bad_bytes ... ok
test dies_bytes_and_lines ... ok

Definieren der Argumente

Willkommen zurück. Ich zeige zunächst das Baumuster mit einer get_args Funktion wie im vorherigen Kapitel. Beachte, dass die beiden optionalen Argumente, lines und bytes, numerische Werte akzeptieren. Dies unterscheidet sich von den optionalen Argumenten, die in Kapitel 3 implementiert wurden und als boolesche Flags verwendet werden. Beachte, dass der folgende Code use clap::{Arg, Command} benötigt:

fn get_args() -> Args {
    let matches = Command::new("headr")
        .version("0.1.0")
        .author("Ken Youens-Clark <kyclark@gmail.com>")
        .about("Rust version of `head`")
        .arg(
            Arg::new("lines") 1
                .short('n')
                .long("lines")
                .value_name("LINES")
                .help("Number of lines")
                .value_parser(clap::value_parser!(u64).range(1..))
                .default_value("10"),
        )
        .arg(
            Arg::new("bytes") 2
                .short('c')
                .long("bytes")
                .value_name("BYTES")
                .conflicts_with("lines")
                .value_parser(clap::value_parser!(u64).range(1..))
                .help("Number of bytes"),
        )
        .arg(
            Arg::new("files") 3
                .value_name("FILE")
                .help("Input file(s)")
                .num_args(0..)
                .default_value("-"),
        )
        .get_matches();

    Args {
        files: matches.get_many("files").unwrap().cloned().collect(),
        lines: matches.get_one("lines").cloned().unwrap(),
        bytes: matches.get_one("bytes").cloned(),
    }
}
1

Die Option lines nimmt einen Wert an und ist standardmäßig auf 10 eingestellt.

2

Die Option bytes nimmt einen Wert an und steht im Konflikt mit dem Parameter lines, so dass sie sich gegenseitig ausschließen.

3

Der Parameter files ist positionsabhängig, kann null oder mehr Werte annehmen und ist standardmäßig ein Bindestrich (-).

Alternativ dazu erfordert das clap Ableitungsmuster die Annotation der Args Struktur:

#[derive(Parser, Debug)]
#[command(author, version, about)]
/// Rust version of `head`
struct Args {
    /// Input file(s)
    #[arg(default_value = "-", value_name = "FILE")]
    files: Vec<String>,

    /// Number of lines
    #[arg(
        short('n'),
        long,
        default_value = "10",
        value_name = "LINES",
        value_parser = clap::value_parser!(u64).range(1..)
    )]
    lines: u64,

    /// Number of bytes
    #[arg(
        short('c'),
        long,
        value_name = "BYTES",
        conflicts_with("lines"),
        value_parser = clap::value_parser!(u64).range(1..)
    )]
    bytes: Option<u64>,
}
Tipp

Im Ableitungsmuster ist der Standard Arg::long Wert der Name des Strukturfeldes, z. B. Zeilen und Bytes. Der Standardwert für Arg::short ist der erste Buchstabe des struct-Feldes, also l oder b. Ich gebe die Kurznamen n bzw. c an, um mit dem ursprünglichen Tool übereinzustimmen.

Es ist ziemlich viel Arbeit, alle Benutzereingaben zu überprüfen, aber jetzt habe ich die Gewissheit, dass ich mit guten Daten fortfahren kann.

Verarbeitung der Eingabedateien

Ich empfehle, dass du deine main eine run Funktion aufrufen lässt. Achte darauf, dass use anyhow::Result für das Folgende:

fn main() {
    if let Err(e) = run(Args::parse()) {
        eprintln!("{e}");
        std::process::exit(1);
    }
}

fn run(_args: Args) -> Result<()> {
    Ok(())
}

Dieses Challenge-Programm sollte die Eingabedateien wie in Kapitel 3 behandeln, also schlage ich vor, dass du die gleiche Funktion open hinzufügst:

fn open(filename: &str) -> Result<Box<dyn BufRead>> {
    match filename {
        "-" => Ok(Box::new(BufReader::new(io::stdin()))),
        _ => Ok(Box::new(BufReader::new(File::open(filename)?))),
    }
}

Vergewissere dich, dass du alle diese zusätzlichen Abhängigkeiten hinzufügst:

use std::fs::File;
use std::io::{self, BufRead, BufReader};

Erweitere deine run Funktion, um zu versuchen, die Dateien zu öffnen und Fehler auszudrucken, wenn du sie findest:

fn run(args: Args) -> Result<()> {
    for filename in args.files { 1
        match open(&filename) { 2
            Err(err) => eprintln!("{filename}: {err}"), 3
            Ok(_) => println!("Opened {filename}"), 4
        }
    }
    Ok(())
}
1

Iteriere durch die einzelnen Dateinamen.

2

Versucht, die angegebene Datei zu öffnen.

3

Druckfehler an STDERR.

4

Druckt eine Meldung, dass die Datei erfolgreich geöffnet wurde.

Führe dein Programm mit einer guten und einer schlechten Datei aus, um sicherzustellen, dass es funktioniert. Im folgenden Befehl steht blargh für eine nicht existierende Datei:

$ cargo run -- blargh tests/inputs/one.txt
blargh: No such file or directory (os error 2)
Opened tests/inputs/one.txt

Ohne einen Blick auf meine Lösung zu werfen, überlege dir, wie du die Zeilen und dann die Bytes einer bestimmten Datei lesen kannst. Als Nächstes fügst du die Kopfzeilen hinzu, die mehrere Dateiargumente trennen. Sieh dir die Fehlerausgabe des Originalprogramms head bei ungültigen Dateien genau an und stelle fest, dass bei lesbaren Dateien zuerst eine Kopfzeile und dann die Dateiausgabe erscheint, bei ungültigen Dateien aber nur ein Fehler ausgegeben wird.Außerdem gibt es eine zusätzliche Leerzeile, die die Ausgabe für die gültigen Dateien trennt:

$ head -n 1 tests/inputs/one.txt blargh tests/inputs/two.txt
==> tests/inputs/one.txt <==
Öne line, four words.
head: blargh: No such file or directory

==> tests/inputs/two.txt <==
Two lines.

Ich habe speziell für dich einige herausfordernde Eingaben entwickelt. Um zu sehen, was auf dich zukommt, benutze den Befehl file, um Informationen zum Dateityp zu erhalten:

$ file tests/inputs/*.txt
tests/inputs/empty.txt:  empty 1
tests/inputs/one.txt:    UTF-8 Unicode text 2
tests/inputs/three.txt:  ASCII text, with CRLF, LF line terminators 3
tests/inputs/twelve.txt: ASCII text 4
tests/inputs/two.txt:    ASCII text 5
1

Dies ist eine leere Datei, um sicherzustellen, dass dein Programm nicht umkippt.

2

Diese Datei enthält Unicode, denn ich habe einen Umlaut über das O in Őne gesetzt, um dich zu zwingen, die Unterschiede zwischen Bytes und Zeichen zu beachten.

3

Diese Datei hat Zeilenenden im Windows-Stil.

4

Diese Datei hat 12 Zeilen, damit der Standardwert von 10 Zeilen angezeigt wird.

5

Diese Datei hat Zeilenenden im Unix-Stil.

Tipp

Unter Windows ist der Zeilenumbruch die Kombination aus Wagenrücklauf und Zeilenvorschub, oft als CRLF oder \r\n dargestellt. Auf Unix-Plattformen wird nur der Zeilenumbruch verwendet, also LF oder \n. Diese Zeilenenden müssen in der Ausgabe deines Programms erhalten bleiben, also musst du einen Weg finden, die Zeilen in einer Datei zu lesen, ohne die Zeilenenden zu entfernen.

Lesen von Bytes versus Charaktere

Bevor du fortfährst, solltest du den Unterschied zwischen dem Lesen von Bytes und Zeichen aus einer Datei verstehen. In den frühen 1960er Jahren repräsentierte die ASCII-Tabelle (American Standard Code for Information Interchange) mit 128 Zeichen alle möglichen Textelemente in der Informatik.Um so viele Zeichen darzustellen, braucht man nur sieben Bits (27 = 128). Normalerweise besteht ein Byte aus acht Bits, also waren die Begriffe Byte und Zeichen austauschbar.

Seit der Einführung von Unicode (Universal Coded Character Set) zur Darstellung aller Schriftsysteme der Welt (und sogar der Emojis) benötigen manche Zeichen bis zu vier Bytes.Der Unicode-Standard definiert mehrere Möglichkeiten zur Kodierung von Zeichen, darunter UTF-8 (Unicode Transformation Format mit acht Bits). Wie bereits erwähnt, beginnt die Datei tests/inputs/one.txt mit dem Zeichen Ő, das in UTF-8 zwei Bytes lang ist. Wenn du möchtest, dass head dir dieses eine Zeichen anzeigt, musst du zwei Bytes anfordern:

$ head -c 2 tests/inputs/one.txt
Ö

Wenn du head bittest, nur das erste Byte aus dieser Datei auszuwählen, erhältst du den Byte-Wert 195, der keine gültige UTF-8-Zeichenkette ist. Die Ausgabe ist ein Sonderzeichen, das auf ein Problem bei der Umwandlung eines Zeichens in Unicode hinweist:

$ head -c 1 tests/inputs/one.txt
�

Das Challenge-Programm soll dieses Verhalten nachbilden. Es ist nicht einfach, dieses Programm zu schreiben, aber du solltest in der Lage sein, mit std::io, std::fs::File, und std::io::BufReader herauszufinden, wie man Bytes und Zeilen aus jeder der Dateien liest. Beachte, dass in Rust eine String eine gültige UTF-8-kodierte Zeichenkette sein muss und daher die Methode String::from_utf8_lossy Ich habe in tests/cli.rs eine ganze Reihe von Tests eingefügt, die du in deinen Quellbaum kopiert haben solltest.

Hinweis

Hör hier auf zu lesen und beende das Programm. Verwende cargo test häufig, um deinen Fortschritt zu überprüfen. Gib dein Bestes, um alle Tests zu bestehen, bevor du dir meine Lösung ansiehst.

Lösung

Diese Aufgabe war interessanter, als ich erwartet hatte. Ich dachte, es wäre nicht viel mehr als eine Variation von cat, aber es stellte sich heraus, dass sie um einiges schwieriger war. Ich zeige dir, wie ich zu meiner Lösung gekommen bin.

Eine Datei Zeile für Zeile lesen

Nachdem ich die gültigen Dateien geöffnet hatte, begann ich damit, Zeilen aus dem Filehandle zu lesen. Ich beschloss, einen Code aus Kapitel 3 zu ändern:

fn run(args: Args) -> Result<()> {
    for filename in args.files {
        match open(&filename) {
            Err(err) => eprintln!("{filename}: {err}"),
            Ok(file) => {
                for line in file.lines().take(args.lines as usize) { 1
                    println!("{}", line?); 2
                }
            }
        }
    }
    Ok(())
}
1

Verwende Iterator::take wählst du die gewünschte Anzahl von Zeilen aus dem Filehandle aus.

2

Gib die Zeile auf der Konsole aus.

Tipp

Die Methode Iterator::take erwartet, dass ihr Argument vom Typ usize ist, aber ich habe einen u64. Ich caste oder konvertiere den Wert mit dem Schlüsselwortas .

Ich finde diese Lösung lustig, weil sie die Methode Iterator::take verwendet, um die gewünschte Anzahl von Zeilen auszuwählen. Ich kann das Programm ausführen, um eine Zeile aus einer Datei auszuwählen, und es scheint gut zu funktionieren:

$ cargo run -- -n 1 tests/inputs/twelve.txt
one

Wenn ich cargo testausführe, besteht das Programm fast die Hälfte der Tests, was ziemlich gut dafür ist, dass es nur einen kleinen Teil der Spezifikationen implementiert hat; allerdings schlägt es bei allen Tests fehl, die die Windows-kodierte Eingabedatei verwenden. Um dieses Problem zu lösen, muss ich etwas gestehen.

Zeilenenden beim Lesen einer Datei beibehalten

Ich sage es dir nur ungern, lieber Leser, aber das catr Programm in Kapitel 3 entspricht nicht ganz dem ursprünglichen cat Programm, denn es verwendet BufRead::lines verwendet, um die Eingabedateien zu lesen.In der Dokumentation zu dieser Funktion heißt es: "Jede zurückgegebene Zeichenkette hat kein Zeilenumbruch-Byte (das 0xA Byte) oder CRLF (0xD, 0xA Bytes) am Ende." Ich hoffe, du verzeihst mir, denn ich wollte dir zeigen, wie einfach es sein kann, die Zeilen einer Datei zu lesen, aber du solltest wissen, dass das catr Programm die CRLF-Zeilenenden von Windows durch Zeilenumbrüche im Unix-Stil ersetzt.

Um dies zu beheben, muss ich stattdessen BufRead::read_lineverwenden, das laut der Dokumentation "Bytes aus dem zugrunde liegenden Stream liest, bis das Trennzeichen (das 0xA Byte) oder EOF gefunden wird. Sobald es gefunden wurde, werden alle Bytes bis einschließlich des Begrenzungszeichens (falls gefunden) an buf angehängt."1 Es folgt eine Version, die die ursprünglichen Zeilenenden beibehält. Mit diesen Änderungen wird das Programm mehr Tests bestehen als fehlschlagen:

fn run(args: Args) -> Result<()> {
    for filename in args.files {
        match open(&filename) {
            Err(err) => eprintln!("{filename}: {err}"),
            Ok(mut file) => { 1
                let mut line = String::new(); 2
                for _ in 0..args.lines { 3
                    let bytes = file.read_line(&mut line)?; 4
                    if bytes == 0 { 5
                        break;
                    }
                    print!("{line}"); 6
                    line.clear(); 7
                }
            }
        };
    }
    Ok(())
}
1

Akzeptiere den Filehandle als veränderbaren Wert.

2

Verwende String::new um einen neuen, leeren, veränderbaren String-Puffer zu erstellen, der jede Zeile aufnimmt.

3

Verwende for, um durch eine Tabelle zu iterieren std::ops::Range zu durchlaufen, um von Null bis zur gewünschten Anzahl von Zeilen zu zählen. Der Variablenname _ zeigt an, dass ich nicht beabsichtige, sie zu verwenden.

4

Verwende BufRead::read_line um die nächste Zeile in den String-Puffer zu lesen.

5

Der Filehandle gibt null Bytes zurück, wenn er das Ende der Datei erreicht, also break aus der Schleife.

6

Drucke die Zeile, einschließlich des ursprünglichen Zeilenendes.

7

Verwende String::clear um den Zeilenspeicher zu leeren.

Wenn ich das Programm cargo test ausführe, besteht das Programm fast alle Tests für das Lesen von Zeilen und schlägt bei allen Tests für das Lesen von Bytes und die Handhabung mehrerer Dateien fehl.

Bytes aus einer Datei lesen

Als Nächstes lese ich Bytes aus einer Datei. Nachdem ich versucht habe, die Datei zu öffnen, prüfe ich, ob args.bytes Some Anzahl der Bytes ist; andernfalls verwende ich den vorhergehenden Code, der Zeilen liest. Für den folgenden Code musst du use std::io::Read zu deinen Importen hinzufügen:

for filename in args.files {
    match open(&filename) {
        Err(err) => eprintln!("{filename}: {err}"),
        Ok(mut file) => {
            if let Some(num_bytes) = args.bytes { 1
                let mut buffer = vec![0; num_bytes as usize]; 2
                let bytes_read = file.read(&mut buffer)?; 3
                print!(
                    "{}",
                    String::from_utf8_lossy(&buffer[..bytes_read]) 4
                );
            } else {
                ... // Same as before
            }
        }
    };
}
1

Nutze den Mustervergleich, um zu prüfen, ob args.bytes Some Anzahl der zu lesenden Bytes ist.

2

Erstelle einen veränderbaren Puffer mit einer festen Länge num_bytes, der mit Nullen gefüllt wird, um die aus der Datei gelesenen Bytes zu speichern.

3

Liest Bytes aus dem Filehandle in den Puffer. Der Wert bytes_read enthält die Anzahl der gelesenen Bytes, die geringer sein kann als dieangeforderte Anzahl.

4

Konvertiere die ausgewählten Bytes in eine Zeichenkette, die möglicherweise nicht gültig ist (UTF-8). Beachte die Bereichsoperation, um nur die tatsächlich gelesenen Bytes auszuwählen.

Wie du gesehen hast, wenn du nur einen Teil eines Multibyte-Zeichens auswählst, kann die Umwandlung von Bytes in Zeichen fehlschlagen, weil Strings in Rust gültiges UTF-8 sein müssen. Die FunktionString::from_utf8 gibt nur dann ein Ok zurück, wenn der String gültig ist, aber String::from_utf8_lossy wandelt ungültige UTF-8-Sequenzen in das unbekannte oder Ersatzzeichen um:

$ cargo run -- -c 1 tests/inputs/one.txt
�

Ich zeige dir einen anderen, viel schlechteren Weg, die Bytes aus einer Datei zu lesen. Du kannst die gesamte Datei in einen String einlesen, diesen in einen Vektor von Bytes umwandeln und dann das erste num_bytes auswählen:

let mut contents = String::new(); 1
file.read_to_string(&mut contents)?; // Danger here 2
let bytes = contents.as_bytes(); 3
print!(
    "{}",
    String::from_utf8_lossy(&bytes[..num_bytes as usize]) // More danger 4
);
1

Erstelle einen neuen String-Puffer, der den Inhalt der Datei enthält.

2

Lies den gesamten Inhalt der Datei in den String-Puffer.

3

Verwende str::as_bytes um den Inhalt in Bytes umzuwandeln (u8 oder vorzeichenlose8-Bit-Ganzzahlen).

4

Verwandle mit String::from_utf8_lossy ein Stück von bytes in einen String.

Wie ich bereits erwähnt habe, kann dieser Ansatz dein Programm oder deinen Computer zum Absturz bringen, wenn die Größe der Datei den Arbeitsspeicher deines Rechners übersteigt. Ein weiteres ernsthaftes Problem mit dem obigen Code ist, dass er davon ausgeht, dass die Slice-Operation bytes[..num_bytes] erfolgreich sein wird. Wenn du diesen Code zum Beispiel mit einer leeren Datei verwendest, fragst du nach Bytes, die nicht existieren. Das führt dazu, dass dein Programm in Panik gerät und sofort mit einer Fehlermeldung beendet wird:

$ cargo run -- -c 1 tests/inputs/empty.txt
thread 'main' panicked at src/main.rs:53:55:
range end index 1 out of range for slice of length 0
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Im Folgenden findest du einen sicheren - und vielleicht den kürzesten - Weg, um die gewünschte Anzahl von Bytes aus einer Datei zu lesen. Achte darauf, die Eigenschaft use std::io::Read zu deinen Importen hinzuzufügen:

let bytes: Result<Vec<_>, _> = file.bytes().take(num_bytes as usize).collect();
print!("{}", String::from_utf8_lossy(&bytes?));

Im vorangegangenen Code ist die Typ-Annotation Result<Vec<_>, _> notwendig, da der Compiler den Typ von bytes als Slice schlussfolgert, das eine unbekannte Größe hat. Ich muss angeben, dass ich ein Vec haben möchte, das ein intelligenter Zeiger auf im Heap zugewiesenen Speicher ist.Die Unterstriche (_) zeigen eine partielle Typ-Annotation an, die den Compiler veranlasst, die Typen zu schlussfolgern.Ohne eine Typ-Annotation für bytes beschwert sich der Compiler entsprechend:

error[E0277]: the size for values of type `[u8]` cannot be known at
compilation time
  --> src/main.rs:50:59
   |
95 |                     print!("{}", String::from_utf8_lossy(&bytes?));
   |                                                          ^^^^^^^ doesn't
   |                                        have a size known at compile-time
   |
   = help: the trait `Sized` is not implemented for `[u8]`
   = note: all local variables must have a statically known size
   = help: unsized locals are gated as an unstable feature
Hinweis

Du hast jetzt gesehen, dass der Unterstrich (_) verschiedene Funktionen hat. Als Präfix oder Name einer Variablen zeigt er dem Compiler, dass du den Wert nicht verwenden willst. In einem match Arm ist er der Platzhalter für die Behandlung von Groß- und Kleinschreibung. Wenn es in einer Typ-Annotation verwendet wird, weist es den Compiler an, den Typ abzuleiten.

Du kannst die Typinformation auch auf der rechten Seite des Ausdrucks angeben, indem du den Turbofish-Operator (::<>) verwendest. Oft ist es eine Frage des Stils, ob du den Typ auf der linken oder rechten Seite angibst, aber später wirst du Beispiele sehen, in denen der Turbofish für einige Ausdrücke erforderlich ist.So würde das vorherige Beispiel aussehen, wenn der Typ stattdessen mit dem Turbofish angegeben würde:

let bytes = file
    .bytes()
    .take(num_bytes as usize)
    .collect::<Result<Vec<_>, _>>();

Das unbekannte Zeichen, das von String::from_utf8_lossy (b'\xef\xbf\xbd') erzeugt wird, ist nicht genau dieselbe Ausgabe, die von der BSD head (b'\xc3') erzeugt wird, was den Test etwas schwierig macht.Wenn du dir die Hilfsfunktion run in tests/cli.rs ansiehst, wirst du sehen, dass ich den erwarteten Wert (die Ausgabe von head) gelesen und dieselbe Funktion verwendet habe, um das, was ungültiges UTF-8 sein könnte, zu konvertieren, damit ich die beiden Ausgaben vergleichen kann. Die Funktion run_stdin funktioniert ähnlich:

fn run(args: &[&str], expected_file: &str) -> Result {
    // Extra work here due to lossy UTF
    let mut file = File::open(expected_file)?;
    let mut buffer = Vec::new();
    file.read_to_end(&mut buffer)?;
    let expected = String::from_utf8_lossy(&buffer); 1

    let output = Command::cargo_bin(PRG)?.args(args).output().expect("fail");
    assert!(output.status.success());
    assert_eq!(String::from_utf8_lossy(&output.stdout), expected); 2

    Ok(())
}
1

Behandle jedes ungültige UTF-8 in expected_file.

2

Vergleiche die ausgegebenen und erwarteten Werte als verlustbehaftete Zeichenketten.

Drucken der Dateitrennzeichen

Wie bereits erwähnt, haben gültige Dateien einen Header, der den Dateinamen in die Markierungen ==> und <== einfügt.Dateien nach der ersten haben einen zusätzlichen Zeilenumbruch am Anfang, um die Ausgabe visuell zu trennen.Das bedeutet, dass ich die Dateinummer wissen muss, die ich behandle, was ich mit der MethodeIterator::enumerate herausfinden kann. Hier ist die endgültige Version meiner run Funktion, die alle Tests besteht:

fn run(args: Args) -> Result<()> {
    let num_files = args.files.len(); 1

    for (file_num, filename) in args.files.iter().enumerate() { 2
        match open(filename) {
            Err(err) => eprintln!("{filename}: {err}"),
            Ok(mut file) => {
                if num_files > 1 { 3
                    println!(
                        "{}==> {filename} <==",
                        if file_num > 0 { "\n" } else { "" }, 4
                    );
                }

                if let Some(num_bytes) = args.bytes {
                    let mut buffer = vec![0; num_bytes as usize];
                    let bytes_read = file.read(&mut buffer)?;
                    print!(
                        "{}",
                        String::from_utf8_lossy(&buffer[..bytes_read])
                    );
                } else {
                    let mut line = String::new();
                    for _ in 0..args.lines {
                        let bytes = file.read_line(&mut line)?;
                        if bytes == 0 {
                            break;
                        }
                        print!("{line}");
                        line.clear();
                    }
                }
            }
        }
    }

    Ok(())
}
1

Verwende die MethodeVec::len , um die Anzahl der Dateien zu ermitteln.

2

Verwende die Methode Iterator::enumerate, um die Dateinummer und dieDateinamen zu verfolgen.

3

Drucke nur Kopfzeilen, wenn es mehrere Dateien gibt.

4

Gib einen Zeilenumbruch aus, wenn file_num größer ist als 0, was die erste Datei anzeigt.

Weiter gehen

Es gibt keinen Grund, die Party jetzt zu beenden. Überlege dir, wie GNU head mit numerischen Werten mit Suffix und negativen Werten umgeht.Zum Beispiel bedeutet -c=1K, dass die ersten 1.024 Bytes der Datei gedruckt werden, und -n=-3 bedeutet, dass alle bis auf die letzten drei Zeilen der Datei gedruckt werden. Du musst lines und bytes in vorzeichenbehaftete Integer-Werte ändern, um sowohl positive als auch negative Zahlen zu speichern. Stelle sicher, dass du GNU head mit diesen Argumenten ausführst, die Ausgabe in Testdateien aufzeichnest und Tests schreibst, die die neuen Funktionen abdecken, die du hinzufügst.

Du könntest auch eine Option hinzufügen, mit der du nicht nur Bytes, sondern auch Zeichen auswählen kannst. Du kannst die FunktionString::chars verwenden, um eine Zeichenkette in Zeichen aufzuteilen.Schließlich kopierst du die Testeingabedatei mit den Windows-Zeilenenden(tests/inputs/three.txt) zu den Tests für Kapitel 3. Bearbeite die mk-outs.sh für dieses Programm, um diese Datei einzubinden, und erweitere dann die Tests und das Programm, um sicherzustellen, dass die Zeilenenden erhalten bleiben.

Zusammenfassung

In diesem Kapitel haben wir uns mit einigen schwierigen Themen befasst, z. B. mit der Konvertierung von Typen wie String-Inputs in u64 und dem anschließenden Casting in usize. Wenn du dich immer noch verwirrt fühlst, solltest du wissen, dass das nicht immer der Fall ist. Wenn du die Dokumentation weiter liest und mehr Code schreibst, wird es irgendwann Sinn machen.

Hier sind einige Dinge, die du in diesem Kapitel erreicht hast:

  • Du hast gelernt, optionale Parameter zu erstellen, die Werte annehmen können. Zuvor waren die Optionen Flags.

  • Du hast gesehen, dass alle Befehlszeilenargumente Zeichenketten sind und hast clap benutzt, um die Umwandlung einer Zeichenkette wie "3" in die Zahl 3 zu versuchen.

  • Du hast gelernt, wie man Typen mit dem Schlüsselwort as umwandelt.

  • Du hast herausgefunden, dass die Verwendung von _ als Name oder Präfix einer Variablen eine Möglichkeit ist, dem Compiler zu zeigen, dass du nicht vorhast, den Wert zu verwenden. Wenn es in einer Typ-Annotation verwendet wird, weist es den Compiler an, den Typ abzuleiten.

  • Du hast gelernt, wie du mit BufRead::read_line die Zeilenenden beim Lesen eines Filehandles beibehalten kannst.

  • Du hast festgestellt, dass die Methode take sowohl bei Iteratoren als auch bei Filehandles funktioniert, um die Anzahl der ausgewählten Elemente zu begrenzen.

  • Du hast gelernt, dass du mit dem Turbofisch-Operator Typinformationen auf der linken Seite einer Zuweisung oder auf der rechten Seite angeben kannst.

Im nächsten Kapitel erfährst du mehr über Rust-Iteratoren und wie man Eingaben in Zeilen, Bytes und Zeichen aufteilt.

1 EOF ist eine Abkürzung für End of File.

Get Befehlszeilen-Rost now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.