Safe C wrappers in Rust - Part 1
In this post I want to highlight some aspects of handling wrapping C APIs in Rust that I encountered while working on libzmq bindings. First, I will give some background information, and then grab a specific part of the C API, and discuss how it is mapped in Rust.
Introduction
libzmq is the canonical implementation of the ZMTP protocol, which provides reliable message-passing abstractions on top of TCP, PGM, IPC (i.e. Unix-domain sockets) and in-process, shared memory queues.
While being implemented in C++, it exposes its API in C, as that allows for a stable ABI across compilers and compiler versions on a given platform (which is not possible to achieve using C++).
Having a C API makes the creation of a low-level, unsafe binding in
Rust quite straightforward. Such a low-level binding is provided by
the zmq-sys
crate, used internally by zmq
and hosted in the same
git repository. However, zmq-sys
does not add anything in terms of
safety or convenience, it just mirrors the C API in Rust.
So, we can regard zmq-sys
as an implementation detail; the
interesting stuff happens in the zmq
crate — it adds safety,
convenience, and tries to expose an idiomatic, "Rusty" API on top of
the functionality offered by libzmq.
A few things that can, need, and should be done in creating a safe and idiomatic binding are quite obvious, such as:
-
Introduce Rust wrapper types around all logical types exposed by the C API. Some of these types may be even represented as a
void *
in C. In Rust, we need strong typing to be able to provide a safe interface. -
Convert C functions that are logically methods or constructors into actual methods and associated functions in Rust.
-
Convert error return codes into a
Error
type, and have all functions that might fail return aResult
type. -
Some logical types may not be as obvious, such that a certain group of bit flags belonging together. For these cases, a new type should be created as well, to allow the compiler to catch combinations of flags that do not make sense. The
bitflags
macro from thebitflags
crate comes in handy to do that. However, some flags may benefit from a less a direct translation, such as providing two different methods which different signatures in Rust. In libzmq, it turned out that the flags that specify non-blocking behavior and multi-part messages are not as straightforward to handle as one might think. I hope to discuss the this in a future post.
In summary libzmq poses several interesting challenges in providing a safe and convenient Rust API on top of it:
-
The
zmq_getsockopt
andzmq_setsockopt
functions are tricky in that they are dynamically typed: depending on a runtime function argument, they will return, or accept, a different type. This is the part of the API I want to discuss in this post. -
There is a thread-safe "context" data type, but the sockets created from it are not thread safe — up to libzmq 4.2. In libzmq 4.2, there are two new socket variants (
ZMQ_CLIENT
andZMQ_SERVER
) that are thread-safe. Note that new features from libzmq 4.2 are not yet supported byzmq
, see issue #122. -
C does not really have a concept of mutability;
const
is not transitive, can be circumvented, and is not generally applied consistently in C APIs.For the Rust wrapper, we want to capture the actual mutability behavior for types where mutability can be observed. For example, a
zmq_msg_t
, which is basically a handle for an array of bytes, can be mutated from C, which means it needs to expose its mutating methods taking a&mut self
in Rust.In other cases, it is not as clear-cut; zmq socket objects are especially interesting. And even for types like
zmq_msg_t
, which seem straight-forward at the first glance, it might be beneficial to introduce a distinction on the type level. This would also be a worthwhile subject for a future post.
Handling dynamic typing
The API under discussion here is the socket option interface, which will look quite familiar to anyone who has used a BSD-style socket API:
int zmq_setsockopt (void *socket, int option_name, const void *option_value, size_t option_len); int zmq_getsockopt (void *socket, int option_name, void *option_value, size_t *option_len);
The option_name
can be chosen from an assortment of option-naming
identifiers evaluating to an integer option ID, e.g. ZMQ_IPV6
or
ZMQ_LINGER
. Not all options are valid for all sockets, some options
may only be applicable for setting, while others may only be read.
But most importantly, the type of the memory passed for option_value
and option_len
will depend on the option ID. This interface cannot
be mapped directly into Rust in a type-safe way.
One solution is to make the dynamic typing of the C interface
explicit, and introduce sum type (i.e. Rust enum
) to represent the
different possible types of values, something like:
enum OptionId { ZMQ_IPV6, ZMQ_LINGER, ... } enum OptionValue { IntOption(i32), BoolOption(bool), StringOption(String), ... }
Now our Rust code method signature, and an invocation may look like this:
impl Socket { fn set_option(&self, option: SocketId, value: OptionValue) { ... } ... } ... socket.set_option(zmq::IPV6, BoolOption(true));
Not too bad, but this still has some drawbacks:
-
There is now an additional runtime cost compared to C, as we need to destructure the option value in
set_option
. This might not be very relevant, as most options are changed or inspected infrequently, if at all. However, it would still be nice to adhere to the "zero-cost abstractions" ideal here. -
We need to wrap option values into their own type. This could be solved using generics and the
Into
trait:fn set_option<T>(&self, option: SocketOption, value: T) where T: Into<OptionValue> { ... }
This would allow leaving out the
BoolOption
wrapper:socket.set_option(zmq::IPV6, true);
-
There is no compile-time relationship between the option's name and its expected value. This is the sticking point: ideally we would like to ensure the type of value matches the one required by the option used.
The approach currently chosen by the zmq
crate is to create separate
functions for each option, exposing the matching type, so we get:
fn set_ipv6(&self, bool) { ... } ... socket.set_ipv6(true);
This ticks off all three points mentioned above, as the type is
statically known. However, we now have lost the ability to abstract
over option values. For instance, while we could express "a list of
socket options" with Vec<(OptionId, OptionValue)>
with the first
approach, this is not possible with the current approach. I intend to
fix this eventually
(issue #134), and may
write another post when the solution has taken shape.