Serielle Library mit Pymonad optimieren

Wenn du dir nicht sicher bist, in welchem der anderen Foren du die Frage stellen sollst, dann bist du hier im Forum für allgemeine Fragen sicher richtig.
Antworten
burli
User
Beiträge: 1156
Registriert: Dienstag 9. März 2004, 18:22

Hi, ich schreibe gerade an einer kleinen Funktionslibrary für eine serielle Schnittstelle mit Pymonad, weil ich die Fehlerbehandlung gern mit Monaden machen möchte statt mit Exceptions. Mein bisheriger Code funktioniert schonmal und sieht so aus

Code: Alles auswählen

from pymonad.either import Left, Right
import serial


def open_serial_port(port, baud_rate, timeout=1):
    try:
        ser = serial.Serial(port, baud_rate, timeout=timeout)
        return Right((ser, 0))
    except Exception as e:
        return Left(e)


def close_serial_port(ser):
    ser, data = ser
    try:
        ser.close()
        return Right(data)
    except Exception as e:
        return Left(e)


def send_data(ser, data_to_send):
    ser, data = ser
    try:
        ser.write(data_to_send.encode('utf-8'))
        return Right((ser, data))
    except Exception as e:
        return Left(e)


def receive_data(ser):
    ser, data = ser
    try:
        data = ser.readline().decode('utf-8')
        return Right((ser, data))
    except Exception as e:
        return Left(e)


if __name__ == "__main__":
    result = (
        open_serial_port('com9', 115200)
        .then(lambda ser: send_data(ser, '++addr\n'))
        .then(lambda ser: receive_data(ser))
        .then(lambda ser: close_serial_port(ser))
    )

    print(result.either(lambda x: f'Sorry, {x}', lambda x: x))
Was wir nicht gefällt ist die Notwendigkeit der Lambdas für den ersten Parameter. In anderen Sprachen wird der Rückgabewert der vorherigen Funktion automatisch als erster Parameter in die nächste Funktion übergeben, ohne ihn angeben zu müssen. Also in etwa so.

Code: Alles auswählen

if __name__ == "__main__":
    result = (
        open_serial_port('com9', 115200)
        .then(send_data( '++addr\n'))
        .then(receive_data())
        .then(close_serial_port())
    )
Ich bin noch nicht ganz durchgestiegen ob und wenn ja wie das mit Pymonad machbar ist. Kennt sich von euch jemand damit besser aus und kann mir da weiterhelfen?
Das schwierigste beim Programmieren ist, sinnvolle Variablen- und Funktionsnamen zu finden :lol:
narpfel
User
Beiträge: 645
Registriert: Freitag 20. Oktober 2017, 16:10

Das Feature, was du beschreibst, ist Currying, und IIRC gibt es da auch Bibliotheken in Python, um per Decorator aus einer normalen Funktionsdefinition eine gecurryte zu machen. Du müsstest dafür deine Funktionen dementsprechend umbauen, dass `ser` als letzter Parameter erwartet wird (wie es z. B. in Haskell üblich ist).

Ich sehe allerdings nicht genau den Sinn darin, auf Biegen und Brechen monadische Fehlerbehandlung zu benutzen und damit gegen die Sprache zu programmieren. Insbesondere machst du damit die Verwendung von Context Managern unmöglich, was IMHO die Vorteile (wenn es in Python überhaupt welche gibt) überwiegt.

Was ist denn der konkrete Use Case, in dem die monadische Version (a) einfacher/besser lesbar oder (b) weniger fehleranfällig oder (c) performanter ist als die pythonische Version mit Exceptions? Oder was ist dein Kriterium, nach dem du beurteilst, welche Version besser ist?
burli
User
Beiträge: 1156
Registriert: Dienstag 9. März 2004, 18:22

Currying hab ich schon probiert, hab es aber nicht hinbekommen. Vielleicht weil ich davon ausgegangen bin, dass der erste Parameter automatisch übergeben wird. So kenne ich das zumindest aus einigen Sprachen. Muss mir das morgen nochmal anschauen.

Hintergrund ist eigentlich nur, dass ich ein wenig in die funktionale Welt schnuppern will ohne gleich eine komplett neue Sprache zu verwenden. Außerdem denke ich, dass sich Error Handling mit Monaden wie zB in Rust immer häufiger zu sehen sein wird
Das schwierigste beim Programmieren ist, sinnvolle Variablen- und Funktionsnamen zu finden :lol:
narpfel
User
Beiträge: 645
Registriert: Freitag 20. Oktober 2017, 16:10

Funktional und Exceptions sind unabhängig voneinander. Haskell hat Exceptions, OCaml hat Exceptions, Rust hat Exceptions, ... Und es gibt einen Haufen nichtfunktionaler Sprachen, die keine Exceptions haben. Und Rust ist jetzt auch nicht unbedingt eine Paradebeispiel einer funktionalen Sprache.

Der Unterschied zu Python ist, dass die drei genannten Sprachen auch Unterstützung für (mehr oder weniger) monadische Fehlerbehandlung haben, mit `do`-Notation (Haskell), `let*` (OCaml) oder dem Fragezeichenoperator (Rust). In Python muss man sich das alles per Hand nachbauen, und verliert andere wichtige Sprachfeatures (in diesem Beispiel Context Manager). Deswegen hinterfrage ich, ob es Sinn macht, das Thema unbedingt mit Python zu lernen.

Das hier (ungetestet) müsste eigentlich das machen, was du willst:

Code: Alles auswählen

from pymonad.either import Left, Right
from pymonad.tools import curry
import serial


def open_serial_port(port, baud_rate, timeout=1):
    try:
        ser = serial.Serial(port, baud_rate, timeout=timeout)
        return Right((ser, 0))
    except Exception as e:
        return Left(e)


def close_serial_port(ser):
    ser, data = ser
    try:
        ser.close()
        return Right(data)
    except Exception as e:
        return Left(e)


@curry(2)
def send_data(data_to_send, ser):
    ser, data = ser
    try:
        ser.write(data_to_send.encode('utf-8'))
        return Right((ser, data))
    except Exception as e:
        return Left(e)


def receive_data(ser):
    ser, data = ser
    try:
        data = ser.readline().decode('utf-8')
        return Right((ser, data))
    except Exception as e:
        return Left(e)


if __name__ == "__main__":
    result = (
        open_serial_port('com9', 115200)
        .then(send_data("++addr\n"))
        .then(receive_data)
        .then(close_serial_port)
    )

    print(result.either(lambda x: f'Sorry, {x}', lambda x: x))
burli
User
Beiträge: 1156
Registriert: Dienstag 9. März 2004, 18:22

narpfel hat geschrieben: Freitag 20. Oktober 2023, 20:27 Rust hat Exceptions
Das wäre mir neu. Ich zitiere aus dem Rust Book
Rust doesn’t have exceptions. Instead, it has the type Result<T, E> for recoverable errors and the panic! macro that stops execution when the program encounters an unrecoverable error.
Es gibt sowas Exceptions zB für Embedded Rust, aber die sind nicht für Error Handling sondern zB für Interrupts.

Ich teste das mit dem @curry Decorator morgen mal aus. Danke für den Hinweis.
Das schwierigste beim Programmieren ist, sinnvolle Variablen- und Funktionsnamen zu finden :lol:
burli
User
Beiträge: 1156
Registriert: Dienstag 9. März 2004, 18:22

narpfel hat geschrieben: Freitag 20. Oktober 2023, 18:56 Du müsstest dafür deine Funktionen dementsprechend umbauen, dass `ser` als letzter Parameter erwartet wird (wie es z. B. in Haskell üblich ist).
Du hattest Recht. Es ist der letzte Parameter. Damit mein Programm funktioniert muss man allerdings vor alle Functionen den @curry() Decorator schreiben, zumindenst mit einer 1
Das schwierigste beim Programmieren ist, sinnvolle Variablen- und Funktionsnamen zu finden :lol:
burli
User
Beiträge: 1156
Registriert: Dienstag 9. März 2004, 18:22

burli hat geschrieben: Samstag 21. Oktober 2023, 06:02 Damit mein Programm funktioniert muss man allerdings vor alle Functionen den @curry() Decorator schreiben, zumindenst mit einer 1
Ok, dass war unnötig. Ich hab nicht gesehen, dass du die Funktionen nur übergibst und nicht aufrufst, also die Klammern weggelassen hast.

Kann man den Code so lassen oder kann man da noch was verbessern?
Das schwierigste beim Programmieren ist, sinnvolle Variablen- und Funktionsnamen zu finden :lol:
narpfel
User
Beiträge: 645
Registriert: Freitag 20. Oktober 2017, 16:10

burli hat geschrieben: Sonntag 22. Oktober 2023, 17:41 Kann man den Code so lassen oder kann man da noch was verbessern?
Ich finde es nicht besonders schön, dass `ser` (was nebenbei kein besonders guter Name ist) gar keine serielle Verbindung ist, sondern ein Tupel. Und dann hat das zweite Element auch noch zwei verschiedene Typen und es gibt einen unnötigen Dummy-Wert.

Wenn du es „richtig schön“ funktional haben willst, solltest du auch einen Typechecker benutzen, und den Code so strukturieren, dass der Typechecker dir dabei hilft, Fehler zu finden. Zum Beispiel könnte es ein statischer Fehler sein, wenn du die Verbindung nicht schließt. Oder `write` oder `read` aufzurufen, nachdem die Verbindung geschlossen wurde. Weil das ja das eigentliche Ziel ist, was man erreichen will: Fehler im Programm zu vermeiden, und das am besten statisch, also durch Type Checking.

Und zu Exceptions in Rust: Panics sind nichts anderes als Exceptions, `raise` wird in Rust `std::panic::resume_unwind` geschrieben und `try/except` ist `std::panic::catch_unwind` in Kombination mit `Box::downcast`. Der einzige Unterschied ist, dass es in Rust keine in die Sprache fest eingebaute Syntax für Exceptions gibt, sondern dass das alles in der Standardbibliothek implementiert ist. Und natürlich noch der Unterschied, dass niemand Panics als Exceptions benutzt. Aber das ist ein kultureller Unterschied und kein technischer. Nichts hindert dich, den Indexoperator so zu implementieren, dass er eine Exception wirft, wenn der Index out of bounds ist, und die Exception dann zu fangen:

Code: Alles auswählen

#[derive(Debug)]
struct OutOfRange {
    len: usize,
    index: usize,
}

impl std::fmt::Display for OutOfRange {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(
            f,
            "attempted to index a Vec of length {} with out-of-range index {}",
            self.len,
            self.index,
        )
    }
}

struct Vec<T>(std::vec::Vec<T>);

impl<T> std::ops::Index<usize> for Vec<T> {
    type Output = T;
    
    fn index(&self, index: usize) -> &Self::Output {
        match self.0.get(index) {
            Some(value) => value,
            None => std::panic::resume_unwind(Box::new(OutOfRange {
                len: self.0.len(),
                index,
            })),
        }
    }
}

fn main() {
    let xs = Vec(vec![1, 2, 3]);
    let index = 42;
    match std::panic::catch_unwind(|| xs[index]) {
        Ok(value) => println!("The value at index {index} is {value}."),
        Err(err) => match err.downcast::<OutOfRange>() {
            Ok(out_of_range) => println!("{out_of_range}"),
            Err(err) => std::panic::resume_unwind(err),
        }
    }
}
(playground)

Äquivalenter Python-Code:

Code: Alles auswählen

def main():
    xs = [1, 2, 3]
    index = 42
    try:
        print(f"The value at index {index} is {xs[index]}")
    except IndexError as e:
        print(e)
        
if __name__ == "__main__":
    main()
burli
User
Beiträge: 1156
Registriert: Dienstag 9. März 2004, 18:22

'ser' muss noch überarbeitet werden. Das heißt deshalb noch so, weil es mal nur für die serielle Schnittstelle war.

Statt einem Tuple könnte man vielleicht auch ein Dict verwenden. Aber ich muss ja irgendwie das Schnittstellenobjekt UND die Daten weiterleiten. (Ich weiß, klingt aus OOP Sicht unlogisch)

Mit dem Typechecker in Python habe ich mich noch nicht auseinandergesetzt. Mich würde da jetzt doch mal interessieren, wie man das umsetzen könnte, was du beschrieben hast.
Das schwierigste beim Programmieren ist, sinnvolle Variablen- und Funktionsnamen zu finden :lol:
__deets__
User
Beiträge: 14545
Registriert: Mittwoch 14. Oktober 2015, 14:29

@narpfel: sehr spannend zu sehen. Durch die Nutzung von Box ist das aber ja zwingend std nach meinem Verständnis. Weißt du zufällig, wie das bei nostd Umgebungen gehandhabt wird?
narpfel
User
Beiträge: 645
Registriert: Freitag 20. Oktober 2017, 16:10

@burli: Das mit dem Tupel klingt auch aus funktionaler Sicht unlogisch. Hier ist mal ein kleines Beispiel in Rust, das deinen Code nachbaut, aber gleichzeitig das Tupel (das in Rust so gar nicht möglich wäre, weil das zweite Element den Typen wechselt) vermeidet:

Code: Alles auswählen

#[derive(Debug, thiserror::Error)]
enum Error {
    #[error("open")]
    Open,
    #[error("read")]
    Read,
    #[error("write")]
    Write,
    #[error("close")]
    Close,
}

struct Serial;

impl Serial {
    fn open() -> Result<Self, Error> {
        Ok(Self)
    }
    
    fn read(&mut self) -> Result<Vec<u8>, Error> {
        Ok(vec![1, 2, 3, 4])
    }
    
    fn write(&mut self, data: &[u8]) -> Result<(), Error> {
        Ok(())
    }
    
    fn close(&mut self) -> Result<(), Error> {
        Ok(())
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let result = Serial::open()
        .and_then(|mut serial| serial.write(b"++addr\n")
            .and_then(|()| serial.read()
                .and_then(|data| serial.close()
                    .and_then(|()| Ok(data))
                )
            )
        );
    println!("{result:?}");
    
    // and using language support in the form of `?`
    let mut serial = Serial::open()?;
    serial.write(b"++addr\n")?;
    let data = serial.read()?;
    serial.close()?;
    println!("{data:?}");
    
    Ok(())
}
(playground)

Man muss einfach die `bind`-Operationen (die bei PyMonad `then` und in Rust `and_then` heißen) verschachteln, genauso wie in Haskell die `do`-Notation zu verschachtelten `bind`s desugart wird. Das sollte so auch in deinem Code funktionieren.

An dem Beispiel sieht man auch sehr schön, wie sehr es hilft, eine Sprache zu benutzen, die diese Art der Fehlerbehandlung explizit unterstützt: Verglichen mit dem Exception-Beispiel (wo der Python-Code viel lesbarer war) ist jetzt der Code mit monadischer Fehlerbehandlung und Fragezeichenoperator in Rust kürzer und lesbarer als der äquivalente Python-Code. In Haskell wäre das ähnlich:

Code: Alles auswählen

do
  serial <- Serial.open
  Serial.write serial "++addr\n"
  data <- Serial.read serial
  Serial.close serial
  pure data
Deswegen meine ich, dass man besser mit der Sprache programmiert als gegen sie.

Man könnte einige Fehler statisch verhindern, indem man das Typestate-Pattern benutzt. Z. B. indem man eine `close`-Funktion schreibt, die eine `Serial`-Instanz konsumiert und dafür eine `ClosedSerial`-Instanz zurückgibt, die dann keine `read`- und `write`-Methoden mehr hat. Und dann könnte man `open` z. B. so schreiben, dass es nicht ein `Either[Error, Serial]` zurückgibt, sondern eine Funktion mit der Signatur `Callable[[Serial], tuple[ClosedSerial, T]]` entgegennimmt. Dadurch hat man dann im Typsystem sichergestellt, dass man `close` aufrufen muss, weil das die einzige Möglichkeit ist, eine `ClosedSerial`-Instanz zu erhalten.

Mit diesem Trick hat man sich dann effektiv ein Feature nachgebaut, das Sprachen mit einem linearen Typsystem fest eingebaut haben: Dass man Werte genau einmal benutzen muss, und der Typechecker wirft einen Fehler, wenn man das vergisst. Auch hier ist es wieder von Vorteil, eine Sprache zu benutzen, die das unterstützt: In Python kann eine Funktion nicht sagen „ich konsumiere mein Argument, das darf danach nicht mehr benutzt werden“. In Rust geht das, da hat man also eine zusätzliche Hilfe vom Typechecker, um Fehler zu vermeiden, die Python (auch mit mypy) nicht bieten kann. Allerdings ist das Typsystem von Rust nicht linear, sondern affin. Das heißt, dass man Werte auch ignorieren darf (man muss sie also höchstens einmal benutzen), also braucht auch Rust in solchen Fällen den Workaround mit dem `Callable` als Parameter.

Der Code hat übrigens noch ein weiteres riesiges Problem: Wenn ein Fehler auftritt, wird `close` nicht aufgerufen. Das solltest du auch noch beheben.

@__deets__: `Box` ist in `alloc`, kann also in der Theorie auch ohne `std` benutzt werden. Das Problem wird eher sein, dass `resume_unwind` und `catch_unwind` in `std` sind. „Exceptions“ scheinen in `no_std`-Umgebungen also eher nicht vorgesehen zu sein.

Wenn man sich die Implementierung von z. B. `catch_unwind` anguckt, dann ist das ein dünner Wrapper über `std::panicking::r#try`, das wiederum ein Wrapper über `std::intrinsics::r#try` ist. Dieses Intrinsic gibt es auch in `core`, also sollte man sich da in nightly vielleicht was zusammenbasteln können?

Die hier gezeigten `no_std`-Panic-Handler scheinen alle mit `loop {}` zu enden, also nicht zu unwinden, sondern das Programm anzuhalten (und optional die Panic-Message zu loggen).

Ich hab aber auch keine Erfahrung mit Bare-Metal-Rust. Wie wird das denn in C++ gehandhabt? Da sind Exceptions (soweit ich weiß, und zumindest unter Linux) ja quasi gleich implementiert, inklusive Allocation und Type Erasure. Kann man da in Freestanding-Umgebungen Exceptions benutzen? Was passiert, wenn eine Exception aus `main` heraus unwindet?
__deets__
User
Beiträge: 14545
Registriert: Mittwoch 14. Oktober 2015, 14:29

Tatsaechlich habe ich C++ in den letzten Jahren nur intensiv mit dem ESP genutzt, der im grunde eine vollstaendige Laufzeitumgebung bereitstellt. Ich benutze da exceptions nicht bewusst, aber die sind "einfach" implementiert wie alles andere auch. Auf wirklich kleinen Micros ist das auch fuer mich eine offene Frage.

Die rp2040 bare metal Rust hat tatsaechlich noch nicht mal alloc. Aber ich habe da auch glaube ich etwas durcheinander geworfen: das Standardmuster mit Result-Werten kann ja auch ohne extra Allokation laufen.
Antworten