Code Style & Taste
A blog where I share thoughts on code and practices

This article is not about your codebase. None of this is likely to apply to you, but it might be fun to see a different viewpoint. In this article, interfaces aren't for abstracting but for being able to do work on types with wildly different behavior.

An Interface Is a Set of Functions

An interface is a type that describes a set of virtual functions. Usually, the interface has no implementation, but some languages allow it, which enables you to report a generic error for anything not implemented. Many people consider interfaces as an abstract object, such as a generic collection. Now, why a person may want to insert values into a collection that could be a queue, a stack, or a set, all of which iterate differently, and may not keep duplicate values? I don't know, but more than one language has a collection interface.

In this codebase, interfaces are simply a set of functions, nothing more. They don't promise anything except that they have a set of functions to satisfy an interface. I'll go over the 3 major types of interfaces used in the codebase: IO, Messages, and Commands.

IO Interface

In our codebase, we have two IO interfaces: IOSource and IOSink. Typically, we use concrete types instead of the interface. For reading, it depends on whether the data is text or binary; for writing, we typically use the concrete class MemoryBuffer. MemoryBuffer accepts an IOSink and a buffer style in its constructor. Instead of infinitely growing, the buffer style may suggest flushing once the data is larger than a page, never until flush is manually called, or when a newline is seen, which is handy when you're logging and want to see the results immediately.

For reading, one popular class is StreamReader. It accepts an IOSource and an optional MemoryBuffer; the MemoryBuffer will receive a copy of the data being read from IOSource. This is handy when you're reading text from a tcp packet or pipe. When you pass in a MemoryBuffer using stdout/stderr as its sink, and set the buffer style to newline, you'll see completed lines in the console while debugging. Alternatively, you can have a file as a sink and examine the data outside the program.

The IO interfaces promise nothing. For sinks, all you know is you can call a write and a flush function; they may do absolutely nothing (NullSink), be transformed and remain in memory (compress), write to disk (FileWriter), or send data over a socket (TCPSocket), all of which have different goals. If you pretend this interface is an abstraction, you'll have a worthless promise that the class has a write and a flush function. IMO that's completely unhelpful. If you switch NullSink and Compress, you'll have a memory leak with code expecting NullSink (data is compressed and grows forever), or you'll lose data (NullSink compresses real well?). If you think of an interface as a way for two classes to communicate without knowing about each other, you're closer to the reality of this codebase.

Message Interface

Messages are for thread-to-thread communication. Our message interface is pretty simple. It is `WorkResult work(ContextVariant context)`. WorkResult is an enum of `More, Finished, Destroy`. 'More' will move the message to the end of the queue, 'Finished' removes it, 'Destroy' removes it, and calls the cleanup function. The context for each type of thread is different; IOThreads defines its parameter as 'IOContext context', while workers use 'Worker worker'. Thread local storage is a fine alternative to a context parameter, but it's harder to use when testing.

The messages are completely unknown, and the message queue does not care what the concrete type is. A message usually contains data, which it may share or give to another thread. A message might be asking an IO thread to load a file, or ask a worker thread to query data. A message may traverse all of these threads, having the IO load a file, moving the file to a worker thread to be processed, then sent back to the main thread to be displayed, provided the message implements each interface.

Command Interface

The command interface is hard to explain and can be completely driven by data. Since it works so differently from other code and interfaces, I like to take a moment to explain it. You can skip this section if you don't care about data-driven code. Commands are created by configuration files or any kind of data, and tend to be immutable once they're instantiated. It produces work by applying commands to parameters. There are 3 steps to implementing commands. 1) Associating command names to 'make' functions 2) Using data to build the list of commands 3) Executing the commands.

Imagine a keymap for a text editor. A configuration file may say pressing "F1" should "SetTheme dark", "Shift F1" should "SetTheme light", and "F5" should "RunDebugger". The first part is associating all the command names (SetTheme and RunDebugger) with their make functions. In our case, we can map the name and make function in a simple hashmap. The make function looks like

static KeymapCommand make(string[] args)

The second part is creating the commands. For this example, we can use another hashmap (we'll call it keymapCommands). When parsing the config file, each line has a button (and an optional modifier) which we'll use as the hashmap key. Next is the command name/id (such as SetTheme), followed by arguments (such as "dark"). We can check if the name exists in our previous hashmap (the one that maps names to make functions), and pass the arguments in the config file as arguments to the make function. The produced object will be the value for our keymapCommands. This is our command list despite not being a list data structure.

The third part is using the command. When a user presses a button (F1 or Shift+F1 in our example), we can check if it's in our keymapCommands, and if it exists, we call the run function. Below is the interface. The return value is used to know if the command ran successfully; if it didn't (such as invalid context), the code can fall back to the default behavior.

bool run(Context context)

As you can see, this has been completely data-driven. The commands were created by a config file at runtime, and executing the commands happens by looking at external input. Like the previous sections, the concrete classes implementing the interface have completely different behavior from eachother. A command may launch a build system, move the cursor, open a menu, or open a frequently modified file. The only thing in common is that they all have an implementation for the run function for the command interface.

Closing thought

In our codebase, interfaces are sets of functions with classes that have nothing to do with other classes that implement the same interface. Often, people think of interfaces as an abstraction where one thing can be substituted for another; however, in our codebase, none of these have any business being substituted for another. The interfaces are entirely so we have a way to make progress on an object with an unknown behavior. In the IO interface, we may use an IOSink and often can't observe the data put into the sink. In the message interface, we want to process messages, but there's no reason to restrict what a message can be or do. For the command interface, we're executing code that was chosen by data, and the results produced by it aren't for us.

Maybe one day we'll use an interface as an abstraction, where a hashmap can be substituted for a different hashmap, but not today.