The Creation of Interfaces in Imperative Languages
This article continues the previous one, where I delved into the significance of interfaces, highlighting their benefits and issues. In the present article, we’ll examine interfaces through practical examples to understand how they’re organized from a language design perspective and explore their advantages and disadvantages. Additionally, I’ll explain how interfaces can be implemented in those programming languages that lack them (as a keyword).
Go
Indeed, in the Go programming language, support for interfaces is inherent from the get-go, as its creators intended to design a language that allows developers to rediscover the joy of programming. To create an interface in Go, you need to define it:
|
As of Go 1.18, Generics can be used:
|
Indeed, the utilization of interfaces in programming enables the division of code into more independent components and reduces the degree of coupling between them (decoupling). This simplification not only facilitates testing but also enhances the overall structure of the application.
For testing code that utilizes interfaces, a common practice is the creation of mock objects — stand-ins for real objects that simulate their behavior within the testing framework. Specific libraries are available for creating mock objects in Go, such as mockery or go-mock. These libraries allow the creation of interface substitutes that can be configured to return certain values or invoke specific methods in response to given parameters. This aids in conducting more comprehensive and accurate code testing and enhances its quality.
|
As can be seen, this approach allows us to declaratively define what the function will return in response to a particular request, thus liberating us from the necessity of implementing the function during testing. This clear delineation makes it easier to test and verify the behavior of the code, contributing to a more robust development process.
Here’s a list of some of the most common default interfaces in Go:
- fmt.Stringer: This interface defines a single method
String() string
, which returns a string representation of the object. Any type that has a methodString() string
automatically implements thefmt.Stringer
interface. - error: This interface defines a single method
Error() string
, returning a string that describes an error. Any type with a methodError() string
automatically implements theerror
interface. - io.Reader: This interface defines a single method
Read(p []byte) (n int, err error)
, reading up tolen(p)
bytes intop
, and returning the number of bytes read and an error if present. - io.Writer: This interface defines a single method
Write(p []byte) (n int, err error)
, writinglen(p)
bytes fromp
into the underlying data stream. - io.Closer: This interface defines a single method
Close() error
, closing the underlying data stream and returning an error if present. - sort.Interface: This interface defines three methods
Len() int
,Less(i, j int) bool
, andSwap(i, j int)
, used to implement sorting algorithms. - context.Context: This interface defines several methods that are used for managing the context of a request or operation, including methods for handling deadlines, cancellations, and storing and retrieving values.
When it comes to interfaces in Go, they are typically designed to be as minimal and compact as possible. This design philosophy promotes flexibility and ease of use, enabling developers to construct more modular and maintainable code. By adhering to small, single-responsibility interfaces, Go fosters a programming environment where code can be easily tested, extended, and reused, aligning with the principle of composition over inheritance.
I would like to highlight the context.Context
interface, widely used in Go for passing execution context between goroutines and for canceling operations. Its definition is as follows:
|
context.Context
contains four methods: Deadline()
, Done()
, Err()
, and Value()
, which allow for determining the time of operation execution, tracking the state of an operation, obtaining errors, and passing values between functions associated with the same context.
In most functions in Go, context.Context
is utilized, enabling the determination of when to halt execution and what data may still be accessible within the context. Utilizing context.Context
is a crucial part of development in Go, as it enables efficient resource management and prevents goroutine leaks, thus ensuring more stable and secure application operation.
The context simultaneously serves as a means of synchronization and a description of an arbitrary context. While the former is understandable, there are some questions about the latter. Why, in strictly typed Go with all its capabilities, is such an entity needed for the transfer of untyped data?
The context in Go is designed to convey values and metadata between different system components, including goroutines. An analogy can be drawn with the HTTP protocol. If you create a simple application that returns a response to a request and place NGINX, a load balancer, and other services in front of it, you’ll find that both the request and response contain headers. These headers may hold user-specific data (such as authentication details) as well as ancillary data generated by intermediate components (like trace identifiers and server names). Similarly, the context in Go may contain both user-defined values and auxiliary information essential for performing a task.
The decision to allow untyped data in the context may seem unorthodox in a language like Go, but it is rooted in a pragmatic approach. By facilitating the propagation of metadata across the boundaries of different system components, it allows for more flexible orchestration of operations and interactions, something akin to attaching metadata to a network protocol. This pattern provides a standardized way to convey essential execution parameters without enforcing a rigid structure, allowing for both innovation and integration with diverse system components. It’s a trade-off that prioritizes flexibility and interoperability at the cost of strong typing in this specific aspect of the language.
The context in Go allows values to be passed between functions up and down the call stack, including the function described in the interface and functions above and below it. To access these values, it’s necessary to properly check for the presence of a value with a specific key in the context. The context can also contain interfaces for access to databases, logging, or telemetry, depending on the conditions.
Moreover, it enables the avoidance of singletons, which have recently been recognized as an anti-pattern. However, this comes with the cost of dynamic type checking and the need to explicitly specify the information a function expects to find in the context.
An important question also arises regarding where the extraction from the context of the information specifically needed in a function should take place. For instance, if the context carries information about logging (such as a file for log output), where should we extract it? In the function that initiates the logging or in the function that actually writes to the log?
So, if:
|
Or:
|
To solve such a question, we simply need to try to ensure transparency. That is, it would be better to write two functions: one will explicitly extract from the context, and the other will write to the log.
|
Here, you can move logging into a separate package, then its call will be reduced to something like:
|
Or implement a function that logs via information in the context:
|
Which will reduce the amount of boilerplate.
In summary:
- Interfaces are strictly typed, do not assume the presence of any implementation or fields;
- They strive to be as small as possible;
- They often carry a context, which is used as a means of conveying information about synchronization and other auxiliary information;
- Efforts should be made to describe as explicitly as possible what will be in the interface, as this will reduce the amount of fuss during debugging.
Python
In the previous section, we looked at a typed language. But what if we don’t have strict types?
Since Python provides extensive metaprogramming capabilities, it has abstract classes that can be used to extend the language’s capabilities.
|
Of course, this can be rewritten using type annotations and thus obtain checks before the program starts running:
|
However, even the use of the abc
library is not mandatory:
|
But for type checking, inheritance from a higher-level entity is still necessary. When using the typing
library, it would be Protocol
.
|
The abc
library also allows combining the definition of an abstract method with property
and classmethod
:
|
Dependency inversion in Python looks like this:
|
Since Python offers many possibilities for metaprogramming, there are more ways you can shoot yourself in the foot when using abstractions than in Go.
For instance:
|
Despite the syntactic correctness, we defined a method in an abstract class, leading to the emergence of a chimera that is not only a declaration but also partially defines an abstraction.
Of course, for abstractions to be fully effective, we need to have type checking. For example, the *
operator multiplies and also duplicates strings, so the following code is correct if we don’t have type checking:
|
In summary:
- Interfaces (abstract classes) can be made using various methods, with different libraries (or without them).
- They become truly powerful only with type annotations.
- There are many ways to make mistakes when using them.