Skip to content

Idomatic APIs #28

@jessebraham

Description

@jessebraham

This issue was migrated from GitLab. The original issue can be found here:
https://gitlab.com/susurrus/serialport-rs/-/issues/112

Opening this issue to discuss the possibility of moving towards more idiomatic Rust APIs in the future. The changes I'm suggesting would be significant API changes, so would require a major-version update to implement.

Concrete Types

In the Rust standard library, types like File and TcpStream are implemented as structs with platform-dependent internals. In serialport, the user has to decide whether to use platform dependent or platform agnostic types ahead of time when developing their application. This forces the developer into two possible paths which both have some noteworthy downsides.

Going the platform-agnostic route, you will be using Box<dyn SerialPort> as your port type. This causes an unnecessary dynamic dispatch and makes it very difficult if you later discover you do need some platform specific functionality. Since the correct implementation of SerialPort on a given platform is always known at compile time, there will never be a case where client code actually needs dynamic dispatch to work with both TTYPort and COMPort polymorphically. If you start out using this platform-agnostic setup and later find out you need to set some platform-specific setting, you have to change all code leading to the spot where the platform-specific setting is used into platform-specific code. That is, Box<TTYPort> is convertible to Box<dyn SerialPort>, but because SerialPort doesn't inherit from Any or provide similar downcast methods, there's no way to get back a TTYPort from dyn SerialPort if you only need a platform-specific method in one place.

Going the platform-specific route, you will be using either TTYPort or COMPort, depending on platform. Because these two types have different names, this makes it fairly inconvenient to write platform-agnostic code for parts of your application that don't depend on a particular platform. You either have to use a bunch of generics or write your own platform-specific aliases of #[cfg(unix)] type Serial = TTYPort;, etc.

This proposal is to follow the pattern of the standard library for the SerialPort type: make the main type provided by the crate a concrete struct with opaque internals that vary by platform. Platform specific methods can either be provided directly on that type conditional on the platform, or can be provided by a platform-specific extension trait, similar to MetadataExt.

Advantages

  • One concrete type used for both platform-specific and platform-agnostic operations.

    • Avoids unnecessary dynamic dispatch.
    • Clients can easily use platform-specific features on an as-needed basis without having to change types.
    fn somewhere_down_in_my_app(port: &mut SerialPort) -> Result<()> {
      // do some stuff...
      // I was being platform agnostic, but now I need to do one platform-specific thing temporarily. NBD!
      #[cfg(unix)] {
          use serialport::unix::SerialPortExt;
          port.set_exclusive(false)?;
      }
      // ...
      Ok(())
    }
    • Simpler type-name.

Disadvantages

  • Clients cannot provide their own implementations of SerialPort.

    As I see it, the core value of this crate is in providing an implementation of serial ports for Rust across several major platforms, not in providing a generic interface to be used across many external implementations. If there are other useful implementations of the SerialPort trait, they could instead be added to this as additional platform-conditional implementations through pull-requests.

    If there are clients that need to generically handle both the SerialPort type and their own custom types, but where they don't have a fully-new implementation of SerialPort worth adding to this crate, then I would suspect that they could likely cover much of their functionality through other traits, either existing ones like Read and Write or their own custom traits implemented just for the shared functionality that they need, just as one could implement custom traits for File.

  • Concrete types are harder to mock in tests.

    I would suspect that a lot of client code that interacts with SerialPort but wants to test against a "mock" would be reasonably served by being generic over either Read, Write, or Read + Write, and then just using standard library types that implement those, such as Vec or Cursor.

    That said, there is the possibility of needing to test interactions with actual serial-port features, such as setting baud rate, in an environment where you don't want to or can't open an actual port. So there is an actual case for having a trait that covers those methods. If that's a significant concernt, I would propose having a trait like SerialPortSettings which provides all the methods for changing baud rate, etc. For convenience in the common use case I might still have the same methods available directly on the concrete SerialPort struct (so they work without importing the trait), but having a settings trait available would allow for those kinds of tests, if you think that's a significant thing clients want to be able to do. I will note for example that TcpStream provides the methods for setting timeouts directly on the struct, and if users want to use that generically to check it in tests, they have to create their own trait for it.

Implemeting for Immutable References

While the Read and Write traits both take &mut self in arguments, many standard library types for working with files/sockets and similar objects implement Read and Write for both T and &T, thereby allowing both reads and writes even if one only has an immutable reference to the type.

I don't know if this would work safely for serial ports, since I don't know what the concurrency-safety of serial ports is across different operating systems. However, if it is possible to safely share the RawFd/RawHandle of a serial port across multiple threads, then I think it would be good to impl Read for &TTYPort etc., as a way to allow easier concurrent use. It's certainly possible to use try_clone/try_clone_native to get multiple handles to the same serial port to share one port in multiple threads, but if a single handle could be shared between threads, that might be more convenient in some cases. For example, you could then share a serial port in an Arc without having to make an OS-level copy of the handle every time you share it, or could share by simple reference-borrow in a case like crossbeam::scope.

Discussion/Implementation

The proposed change to concrete types in particular would be a very significant change in API, even compared to the kinds of changes you've made in previous major-version bumps, so such a change is probably not to be made lightly on a crate with a couple hundred downloads per day.

The suggestion to use a concrete type is made from the perspective of coming here from the standard library, but I don't really have great context for why the library was designed this way in the first place, so let me know if there's strong reasons for setting up the library this way.

If any of this does seem like a good idea, I would be able to to put together pull requests for it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    migratedThis issue was migrated from GitLab

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions