Programmieren in Rust

Beispiele: Makroprozessor

Inhaltsverzeichnis

  1. Aufgabe
  2. Naive Umsetzung
  3. Einsatz eines Iterators
  4. Byteketten

Aufgabe

Ziel ist ein sehr einfacher Makroprozessor. Darunter ist ein Programm zu verstehen, welches Befehle in einem Text gegen Zeichenketten ersetzt, ähnlich der Interpolation von Zeichenketten. Zur Vereinfachung betrachten wir lediglich parameterlose Makros. Die Makros seien von geschweiften Klammern umschlossen, analog zu den Platzhaltern in den Schablonen zur Formatierung von Zeichenketten.

Definiert man beispielsweise Makros für die Verknüpfungssymbole der Aussagenlogik, dann wird die Zeichenkette

A {and} (A {imp} B) {imp} B

zu

A ∧ (A → B) → B

expandiert.

Naive Umsetzung

Bestandteil der Expansion muss ein kleiner Parser sein, der die Makros vom Rest der Eingabe trennt. Hier muss man sich überlegen, wie man genau vorgehen will, denn aufgrund der UTF-8-Kodierung tun sich mehrere Möglichkeiten auf.

Die erste Variante des Programms zeigt eine recht unkomplizierte Vorgehensweise.

static MACROS: &[(&str, fn() -> String)] = &[
    ("not", || String::from("¬")),
    ("and", || String::from("∧")),
    ("or",  || String::from("∨")),
    ("imp", || String::from("→"))
];

fn expand_macro(name: &str) -> Result<String, String> {
    for t in MACROS {
        if t.0 == name {return Ok(t.1());}
    }
    Err(format!("Makro '{}' wurde nicht gefunden.", name))
}

fn expand(text: &str) -> Result<String, String> {
    let a: Vec<char> = text.chars().collect();
    let n = a.len();
    let mut acc = String::new();
    let mut i = 0;
    while i < n {
        if a[i] == '{' {
            i += 1;
            let mut name = String::new();
            while i < n && a[i] != '}' {
                name.push(a[i]);
                i += 1;
            }
            acc.push_str(&expand_macro(&name)?);
            i += 1;
        } else {
            acc.push(a[i]);
            i += 1;
        }
    }
    Ok(acc)
}

fn main() {
    println!("{:?}", expand("A {and} (A {imp} B) {imp} B"));
}

Einsatz eines Iterators

Man kann das vorherige Programm als ein wenig ineffizient betrachten, weil es vermeidbare Speicherallokationen enthält. Dies wurde im folgenden Programm beseitigt, im Wesentlichen indem die aufwändige Umwandlung des Textes zu einem Zeichen-Array ausgetauscht wurde durch die Benutzung eines Iterators.

static MACROS: &[(&str, fn(&mut String))] = &[
    ("not", |acc| acc.push_str("¬")),
    ("and", |acc| acc.push_str("∧")),
    ("or",  |acc| acc.push_str("∨")),
    ("imp", |acc| acc.push_str("→"))
];

fn expand_macro(acc: &mut String, name: &str) -> Result<(), String> {
    for t in MACROS {
        if t.0 == name {return Ok(t.1(acc));}
    }
    Err(format!("Makro '{}' wurde nicht gefunden.", name))
}

fn expand(text: &str) -> Result<String, String> {
    let mut acc = String::with_capacity(text.len());
    let mut name = String::with_capacity(16);
    let mut it = text.chars();
    while let Some(c) = it.next() {
        if c == '{' {
            name.clear();
            while let Some(c) = it.next() {
                if c == '}' {break;} else {name.push(c);}
            }
            expand_macro(&mut acc, &name)?;
        } else {
            acc.push(c);
        }
    }
    Ok(acc)
}

fn main() {
    println!("{:?}", expand("A {and} (A {imp} B) {imp} B"));
}

Würde lediglich die Grundfunktionalität der Iteration zur Verfügung stehen, wären wir gezwungen, wie gezeigt einen Puffer für die Makronamen zu Verwenden. Allerdings haben wir bei einem Iterator über Zeichenketten zusätzlich as_str zur Verfügung, damit erhält man den verbleibenden Rest der Zeichenkette. Dieses Hilfsmittel ermöglicht die folgende Modifikation.

fn expand(text: &str) -> Result<String, String> {
    let mut acc = String::with_capacity(text.len());
    let mut it = text.chars();
    while let Some(c) = it.next() {
        if c == '{' {
            let rest = it.as_str();
            let mut len = 0;
            while let Some(c) = it.next() {
                if c == '}' {break;} else {len += c.len_utf8();}
            }
            expand_macro(&mut acc, &rest[..len])?;
        } else {
            acc.push(c);
        }
    }
    Ok(acc)
}

Man muss bei der Programmierung solcher Algorithmen genau darauf achten, was man tut. Ersetzen wir

len += c.len_utf8();

gegen die naive Inkrementierung len += 1;, ist Unicode in Makronamen nicht mehr gestattet. Zudem führt die Ausschnitt-Bildung &rest[..len] dann bei bestimmten Unicode enthaltenden Namen zum Programmabbruch. Nämlich immer dann, wenn der Index len durch die fehlerhafte Semantik inmitten einer UTF-8-Sequenz liegt. Das ist beispielsweise bei

expand("{Café}")

der Fall.

Byteketten

Eine weitere Alternative besteht in der Betrachtung des Textes als Bytekette. Als kleines Manko nimmt man dabei in Kauf, dass das Hantieren mit Byteketten nun das gesamte Programm durchzieht.

Ferner wurde lineare Suche gegen eine binäre Suche ersetzt, wobei nun darauf zu achten ist, dass die Makrotabelle ab jetzt lexikographisch sortiert bleibt.

static MACROS: &[(&[u8], fn(&mut dyn FnMut(&[u8])))] = &[
    (b"and", |cb| cb("∧".as_bytes())),
    (b"imp", |cb| cb("→".as_bytes())),
    (b"not", |cb| cb("¬".as_bytes())),
    (b"or",  |cb| cb("∨".as_bytes()))
];

fn expand_macro(acc: &mut Vec<u8>, name: &[u8]) -> Result<(), String> {
    match MACROS.binary_search_by_key(&name, |t| t.0) {
        Ok(i) => Ok(MACROS[i].1(&mut |data| acc.extend(data))),
        _ => Err(format!("Makro '{}' wurde nicht gefunden.",
             std::str::from_utf8(name).unwrap()))
    }
}

fn expand(text: &str) -> Result<String, String> {
    let a = text.as_bytes();
    let n = a.len();
    let mut acc: Vec<u8> = Vec::with_capacity(n);
    let mut i = 0;
    while i < n {
        if a[i] == b'{' {
            i += 1;
            let j = i;
            while i < n && a[i] != b'}' {i += 1;}
            expand_macro(&mut acc, &a[j..i])?;
            i += 1;
        } else {
            acc.push(a[i]);
            i += 1;
        }
    }
    Ok(String::from_utf8(acc).unwrap())
}

fn main() {
    println!("{:?}", expand("A {and} (A {imp} B) {imp} B"));
}

Bei dieser Umsetzung drängt sich auf, die Schnittstelle von expand so zu modifizieren, dass das Programm ausschließlich mit Byteketten arbeitet, womit die Voraussetzung der Korrektheit der UTF-8-Daten entfällt. Nämlich darf die Prüfung der Korrektheit beim Einlesen der Eingabe aus einer Datei dann ebenfalls entfallen, indem man die Datei stattdessen als Binärdatei einlesen lässt. Das macht das Programm nochmals effizienter.