Here's a C++ API for an abstracted I2C master peripheral, such as might be found on a microcontroller – an API which I invented:
class I2C { public: virtual ~I2C() {} virtual void setClockSpeed(ClockSpeed speed) = 0; virtual void enable() = 0; virtual void disable() = 0; virtual ssize_t read(uint8_t target, const uint8_t *subAddress, size_t subAddressBytes, uint8_t *buffer, size_t bufferBytes) = 0; virtual ssize_t write(uint8_t target, const uint8_t *message, size_t messageLength) = 0; };
The details of how an I2C bus actually works aren't so relevant to the current issue, so I've removed the comments. What's interesting here is what the calling code needs to look like: the caller has to set the desired clock-speed, enable the peripheral, read and/or write using it, and then disable it again.
This code has been happily chugging away inside Electric Imp devices of various types for about seven years now. Which is why it comes as such a surprise to me that I now think it's terrible.
The realisation came after spending some time learning the Rust language, whereby I've been trained into caring a lot about object lifetimes – because the compiler actually rejects code containing use-after-free errors and whole swathes of further bugs of that sort. I've seen bugs we diagnosed via in telemetry reports from crashing units in production, which would have been caught as compile-time errors in Rust. So Rust is great, once you've got your head round it; but in fact it's so great that I now find myself writing better C++ because of the experience of writing some Rust.
From an object-lifetime point-of-view, the API listed above is error-prone and so undesirable. It's too easy for client code to get things wrong: calling read() before you've called enable(), calling read() after you've called disable(), or calling setClockSpeed() – er, wait, when should you call setClockSpeed()? Is it only allowed before enable? Or is it only allowed afterwards? Or can it be in either position? What guarantees on that point are offered by implementations of this interface?
An act of API design should be an act of humility; you should, to a certain extent, be aiming to make other people's lives easier at the expense of making your own life harder. I failed to do that here. Offering a read() method that just returns ERR_NOT_ENABLED if you haven't called enable() yet, is just leading callers down the garden path. That ordering should be expressed in the API, so that, in the Rust style, it's a compile-time error to get it wrong (or alternatively, in another situation familiar to Rust programmers, so that the error simply isn't expressible in the first place).
And how can that be arranged? By not giving the caller access to the read() method until they've called the enable() method. What if we remove the read() and write() methods from class I2C altogether, and put them in a "handle" class which callers can't get hold of except by enabling the peripheral? This ends up looking like the API below:
class EnabledI2C { public: virtual ~EnabledI2C() {} virtual ssize_t read(uint8_t target, const uint8_t *subAddress, size_t subAddressBytes, uint8_t *buffer, size_t bufferBytes) = 0; virtual ssize_t write(uint8_t target, const uint8_t *message, size_t messageLength) = 0; }; class I2C { public: virtual ~I2C() {} virtual std::unique_ptr<EnabledI2C> open(ClockSpeed cs); };
The disable() method has disappeared altogether; when the calling code has finished with its I2C controller, it can just reset or destroy the unique_ptr, and the destructor of EnabledI2C (or rather, the destructors of its subclasses) can do the work of disable(). Likewise, the work of enable() can be done in the constructor, bringing some RAII to the situation and making the implementations, as well as the calling code, less error-prone.
Without a method called "disable", "enable" looked like the wrong name, so it became "open". And this refactoring has also cleared up the ambiguity around setClockSpeed(); in fact, one implementation of this I2C interface allowed it to be called at any time, even in-between reads, but in another implementation it only took effect at enable() time. In practice, the calling code all expected it to work in the latter way, so we can codify that directly into the new API by incorporating it into the "enable" ("open") call itself, so that it's impossible to call it too early or too late.
And again but in C
C++'s support for RAII definitely adds both neatness and robustness to the code here. But part of the principle – that where API calls must be ordered or nested in order to work properly, that ordering or nesting should be enforced by the code rather than meekly whined-about by its documentation – applies even in a C version of the same API:
struct I2C; // opaque struct EnabledI2C; // opaque EnabledI2C *I2C_OpenEnabled(I2C *i2c, unsigned clockSpeed); ssize_t EnabledI2C_Read(EnabledI2C *ei2c, int8_t target, const uint8_t *subAddress, size_t subAddressBytes, uint8_t *buffer, size_t bufferBytes); ssize_t EnabledI2C_Write(EnabledI2C *ei2c, uint8_t target, const uint8_t *message, size_t messageLength); void EnabledI2C_Close(EnabledI2C **pei2c);
Without RAII, the "close" corresponding to the "open" must be explicit, but at least it's still not possible for the client to call things in the wrong order.
And again but in Squirrel
This part is a bit of a cheat; the Electric Imp Squirrel API isn't actually implemented in Squirrel; rather, it's a set of bindings from Squirrel to the C++ code in the improm. But even so the same pattern can apply; here's (the native-Squirrel equivalent of) what it currently looks like:
class I2C { function configure(clockSpeed) { ... } function read(deviceAddress, registerAddress, numberOfBytes) { ... } function write(deviceAddress, registerPlusData) { ... } function disable() { ... } };
This API is used by our customers left, right, and centre, and we can't ever change it. (We could extend it, perhaps.) But if we had our time over again, I'd have done it like this:
class I2C { function open(clockSpeed) { ... return EnabledI2C(...); } }; class EnabledI2C { function read(deviceAddress, registerAddress, numberOfBytes) { ... } function write(deviceAddress, registerPlusData) { ... } };
As in C++, the disable() call has disappeared, because it's all done in the destructor of the EnabledI2C object, which is called when the Squirrel reference-counting system determines that the object is no longer being used. The calling code might look like this:
local e = hardware.i2cAB.open(100000); e.read(...); e.write(...); e = null;
Alternatively, it could just rely on e going out of scope at the end of the function, instead of explicitly setting it to null. But the cheating here has intensified: native Squirrel does not include destructors, and so doesn't allow for RAII. However, Squirrel objects that are bindings to other languages – such as the C++ bindings used by the Electric Imp API – can have destructors using Squirrel's sq_setreleasehook mechanism, and so can be used with RAII.
No comments:
Post a Comment