ð Interface Design Philosophy
Interfaces are the foundation and building blocks of libhal. They are the "A" and "L" in HAL: hardware abstraction layer. They present a generalized ideal of a particular aspect of hardware or computing. For example and output pin represents a pin that can have its output voltage level state changed from a logical true or false value, which may be represented as a LOW voltage or HIGH voltage depending on the device.
The following guidelines describe what should be kept in mind when creating an interface.
Here is an example of some interfaces in libhal. It is recommended to take a look at these to get an idea of how the interfaces are written.
Smallest Possible v-table
When designing an interface aim to have the least number of virtual functions as possible.
Why?
Each virtual function in the interface will require a v-table entry (a pointer)
in the v-table of each implementation of an interface. Each entry takes up
space in the .text
or .rodata
sections of the binary. The more you have the
more space is taken up.
Consider:
Combining APIs if it is possible. For example, lets consider hal::output_pin
and hal::i2c
.
hal::output_pin
could have had a ::high()
and ::low()
API for setting the
pins state. But these could easily be combined into a single API such as
::level(bool)
which accepts the state as an input parameter.
hal::i2c
could have had ::write(...)
, ::read(...)
, and
::write_then_read(...)
. Instead, we have transaction()
which can determine
which of the 3 communication methods to use depending on whether or not the
write and read buffers are supplied. If only one is available, then it will
perform the respective write
or read
operation.
Make virtual functions pure virtual
Interface API implementations are the responsibility of the implementer to be implemented.
Why?
In almost all cases, default behavior does not make sense.
Consider:
The exception to this rule is when a new virtual API is added to the end of the virtual API list. In order to be backwards compatible, the new API MUST be implemented with default behavior. Adding a new virtual API is a last resort and adding a new interface or an additional public class function should be preferred if it can solve the issue.
Eliminate viral behavior
Another way to say this is, "consider the overhead by the developer." This can be space & time overhead in the program or simply the overhead required by the developer in order to use your API correctly.
Why?
Consider the following example of viral behavior through narrow contracts.
Consider this line of code dac.write(value)
. The input to the write
function
only accepts values from 0.0f
to 1.0f
. If value is greater or smaller than
this then it is undefined behavior. The developer, to eliminate this undefined
behavior they must do the following: dac.write(std::clamp(value, 0.0f, 1.0f))
.
This works. The concern here is that now all code that calls this function
MUST add this clamp to ensure that the behavior is well defined OR have some
other mechanism in place to ensure that value does no exceed the narrow
contract of the write
function. This becomes a vector for bugs and issues in
the code. This viral behavior also leads to duplication of the same clamp code
throughout the application developer's code as well as the interface
implementation code. A well designed implementation would either check that the
input is within the bounds allowed and potentially emit an error or clamp the
value for the user. Now the clamp code is performed at the call site as well as
the implementation. This is a waste of cycles and space.
Consider:
Consider what the caller of API will have to do in order to use your API
correctly as well as the implementor of the API. In the example above, the
solution to this viral behavior is to make the narrow contract into a wide
contract where the public API clamps the input for the user, making all input
(besides NaN
), valid input. That way, the caller can be assured that their
input will be clamped and the implementor can be assured that the value they
get will ALWAYS be the expected values.
Viral behavior can come in different forms that narrow and wide contracts, so great consideration must be taken when writing an API to eliminate such viral behavior.
Private virtual functions
Make virtual functions private. Make them callable via a public interface. Like so:
class my_interface {
public:
void foo() {
driver_foo();
}
bool bar() {
return driver_bar();
}
private:
virtual void driver_foo() = 0;
virtual bool driver_bar() = 0;
};
Why?
If, in the event we need to modify the calling convention of a virtual API, we can do so by altering the public API.
Consider:
hal::motor
and hal::dac
originally had narrow contracts which were widened
to remove eliminate viral behavior. Previously hal::motor
could only accept
values from -1.0f
to +1.0f
. Anything beyond that would result in undefined
behavior. This resulting in two large issues, viral behavior and undefined
behavior. The first causes code bloat in terms of code size, and visual noise
to the reader due to the code needed to clamp the input to motor's power()
API. The second will cause potentially severe and hard to find bugs in the code
which is unacceptable. To resolve this issue, the public API was updated to
clamp the input from the caller before passing the info to the virtual API.
This eliminates the need for the calling code to bounds check the value as well
as eliminates the need for the virtual function implementation to bounds check
the input value. This allows for backwards compatible updates to how a virtual
API is called.
class motor
{
public:
void power(float p_power)
{
auto clamped_power = std::clamp(p_power, -1.0f, +1.0f);
return driver_power(clamped_power);
}
private:
virtual void driver_power(float p_power) = 0;
};
Note
This change is backwards API compatible and ABI compatible but may not be link time compatible, since there may be two definitions of the same class function between statically linked binaries.
Consider the stack, ram and rom requirements of an API
Some API designs have the unwanted side effect of causing the user to provide or allocate a large buffer in order to operate. For example:
class big_buffer {
public:
struct big_struct {
std::array<hal::byte, 10_kB> buffer{};
};
private:
virtual void driver_update(const big_struct& p_buffer) = 0;
};
Why?
This can make interfaces and APIs hard to use in resource constrained systems.
In the example above, in order to call the driver_update
function, you need
to pass it a buffer that takes up 10kB of ram. If this is allocated on the
stack, it could easily overrun a thread's stack. If a device doesn't even have
10kB of ram then this API can never be called on the system. An example of this
would be a display driver where an entire frame buffer is required in order to
update the display.
Consider:
Consider if the input value needs to be so large? Can it be broken up into pieces? Can it implemented in another way that doesn't require a large amount of memory?
Should contain no member
Interfaces should only have public member functions and private virtual member functions. Nothing more.
Why?
The primary purpose of an interface is to define an abstract layer of communication between different parts of a program. Interfaces should ideally be agnostic of how their contracts are fulfilled. Including member fields implies a certain level of implementation detail that detracts from the abstraction.
Adding fields to an interface can lead to tighter coupling between the interface and its implementations. This can complicate the design and increase the difficulty of changes in the future. Implementations are forced to manage state in a specific way, which can reduce flexibility in how they manage their internal states and behaviors.
Consider:
That you do not actually need to add a data member to the interface.
Must not be a template
A templated interface is a class template that is also an interface like so:
template<class PacketSize>
class my_interface {
private:
virtual void write(std::span<const PacketSize> p_payload) = 0;
};
Why?
The above example may seem like a great way to broaden an interface to an unlimited scale, but that is actually a problem. (insert reasons here).
Template interfaces widen the scope and number of interfaces available in libhal in an unbounded way. This can result in additional v-tables for each interface implementation.
Interface instances with different template types will not compatible with each other. Meaning an adaptor of sources would be needed to convert one to another.
Consider:
That this is not necessary. Consider that there exists a generic and specific implementation of an interface. Consider making two interfaces if a single interface would not suffice.
Prefer wide API contracts
A wide contract for an API means that an API can take any type of input for all of the input parameters sent to the API. Meaning that the API is well defined for all possible inputs that could be passed. That does not mean that the implementation of an API will accept all possible inputs. The API could throw an error if the input is beyond what it is capable of working with. But simply means that the API is well defined for the whole range of the inputs.
Why?
It helps eliminate viral behavior and tends to eliminate undefined behavior.
Consider:
The cost of an API having a wide contract? Would this result in viral behavior or eliminate it? Would it result in worse performance? Would it result in increased ram or increased rom utilization? Would it potentially save in all of these. If possible try and guarantee a wide contract if possible and only consider a narrow contract as a last resort. Explain in detail why a narrow contract was chosen, as those are vectors for bugs and undefined behavior.
Do NOT break ABI
ABI stands for Application Binary Interface. A breakage to an ABI is not easy for C++ or other languages to determine. A ABI break can come in many forms but it usually comes as a change between a version of code compiled previously and a version of code compiled now. Such a break can result in memory corruption, invalid input to a function and overall undefined behavior.
Why?
Don't do it! Its bad. But in all honesty, all hell breaks loose if we allow ABI breaks. If we MUST break ABI we MUST update the major version number of the library.
Consider:
With regards to interfaces, given the other rules, there is really only the following possible ABI breaking changes that can occur:
- Changing the return value of a virtual function
- Changing function calling convention.
- Reordering of virtual API within an interface.
- Reordering of members within a returned
struct
orclass
.
These are not allowed due to how they affect how programs generate assembly for each function call. What we are allowed to do is the following:
- Add additional non-virtual public functions.
- Add additional overloads for public functions (we should
[[deprecate]]
old APIs we know to be harmful). - Add additional non-pure virtual APIs below the current set of virtual APIs (should avoid this).
- Add additional fields to a settings
struct
that is passed by reference.
Interface Independence Principle
Interfaces should not be designed to have a relationship with each other
outside of an IS-A or inheritance relationship. An allowable relationship is
one where an interface inherits from another, such as hal::advanced_can
inheriting from hal::can
because it has all the same requirements and some
additional ones.
An example of a relationship that is not acceptable would be if there existed a
wifi
interface and a network socket
interface. Technically, there is a
relationship between these two interfaces. One could even consider that
the wifi interface could be a "producer" or "provider" of sockets once a wifi
connection is established. An API from the wifi
interface could be added that
returns a reference to an available socket
. This couples socket
to wifi
and complicates the implementation of wifi, ensuring that sockets can be
returned. The memory and lifetime of that socket then becomes a concern of
wifi
as well as any of its users. Overall, this results in more complex code
and more coupling than necessary. A better option is to keep everything
independent from each other.
To follow this rule, refrain from:
- Returning an interface from a function in any way
- Taking another interface as an input parameter
Instead, if there needs to be some sort of relationship between interfaces, then this type of relationship should be managed by concrete classes that can take dependent objects with a relationship and manage that relationship.