In object-oriented programming (OOP), the concept of interfaces plays a key role and is closely associated with one of the foundational principles—encapsulation.
Simply put, an interface is a contract that defines the expected behavior between system components, such as how they exchange information. A real-world analogy for this concept can be seen in the Unix philosophy of "Everything is a file." This principle represents access to various resources—documents, peripherals, internal processes, and even network communication—as byte streams within the file system namespace.
The advantage of this approach is that it allows a wide range of tools, utilities, and libraries to work uniformly with many types of resources. In OOP, an interface describes the structure of an object but leaves out implementation details.
Unlike languages like Java, C++, or PHP, Go is not a classically object-oriented language. When asked if Golang is OOP, the creators give an ambiguous answer: "Yes and no." While Go includes types and methods and supports an object-oriented programming style, it lacks class hierarchies (or even classes themselves), and the relationship between concrete and abstract (interface) types is implicit, unlike languages such as Java or C++.
In traditional OOP languages, implementing an interface involves explicitly declaring that a class conforms to it (e.g., public class MyClass implements MyInterface
). The implementing class must also define all methods described in the interface, matching their declared signatures exactly.
In Go, there is no need for an explicit declaration that a type implements an interface. As long as a type provides definitions for all the methods specified in the interface, it is considered to implement that interface.
In the Java example below, the class Circle is not an implementation of the interface Shape
if the class description does not explicitly declare that it implements the interface, even if it contains methods matching those in Shape
. In contrast, the class Square
would be recognized as a Shape
implementation because it explicitly declares so.
// Shape.java
interface Shape {
public double area();
public double perimeter();
}
// Circle.java
public class Circle {
private double radius;
// constructor
public Circle(double radius) {
this.radius = radius;
}
public double area() {
return this.radius * this.radius * Math.PI;
}
public double perimeter() {
return 2 * this.radius * Math.PI;
}
}
// Square.java
public class Square implements Shape {
private double x;
// constructor
public Square(double x) {
this.x = x;
}
public double area() {
return this.x * this.x;
}
public double perimeter() {
return 4 * this.x;
}
}
We can easily verify this by creating a function calculate
that accepts an object implementing the Shape
interface as an argument:
// Calculator.java
public class Calculator {
public static void calculate(Shape shape) {
double area = shape.area();
double perimeter = shape.area();
System.out.printf("Area: %f,%nPerimeter: %f.");
}
public static void main() {
Square s = new Square(20);
Circle c = new Circle(10);
calculate(s);
calculate(c);
}
}
If we try to compile such code, we will get an error:
javac Calculator.java
Calculator.java:16: error: incompatible types: Circle cannot be converted to Shape
calculate(c);
^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error
In Golang, there is no requirement for a type to declare the interfaces it implements explicitly. It is sufficient to implement the methods described in the interface (the code below is adapted from Mihalis Tsoukalos's book "Mastering Go"):
package main
import (
"fmt"
"math"
)
type Shape interface {
Area() float64
Perimeter() float64
}
type Square struct {
X float64
}
func (s Square) Area() float64 {
return s.X * s.X
}
func (s Square) Perimeter() float64 {
return 4 * s.X
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return c.Radius * c.Radius * math.Pi
}
func (c Circle) Perimeter() float64 {
return 2 * c.Radius * math.Pi
}
func Calculate(x Shape) {
fmt.Printf("Area: %f,\nPerimeter: %f\n\n", x.Area(), x.Perimeter())
}
func main() {
s := Square{X: 20}
c := Circle{Radius: 10}
Calculate(s)
Calculate(c)
}
Area: 400.000000,
Perimeter: 80.000000
Area: 314.159265,
Perimeter: 62.831853
If we try to use a type that does not implement the Shape
interface as an argument for the Calculate
function, we will get a compilation error. It is shown in the following example, where the Rectangle
type does not implement the Shape
interface (the Perimeter
method is missing):
package main
import "fmt"
type Shape interface {
Area() float64
Perimeter() float64
}
type Rectangle struct {
W, H float64
}
func (r Rectangle) Area() float64 {
return r.W * r.H
}
func Calculate(x Shape) {
fmt.Printf("Area: %f,\nPerimeter: %f\n\n", x.Area(), x.Perimeter())
}
func main() {
r := Rectangle{W: 10, H: 20}
Calculate(r)
}
./main.go:25:12: cannot use r (variable of type Rectangle) as type Shape in argument to Calculate:
Rectangle does not implement Shape (missing Perimeter method)
Notice how the Golang language compiler provides a more informative error message, unlike the Java language compiler.
On the one hand, this approach to interface implementation simplifies writing programs, but on the other hand, it can become a source of errors that are sometimes hard to catch.
Let’s look at an example. While working on a client library for a popular API, we needed to implement a caching mechanism — saving already retrieved data locally "on the client" to avoid repeated requests to the remote API server. API access was provided within packages that had a limited number of requests per month, so using a caching mechanism was economically beneficial for users. However, since the use cases for this library weren't limited to just web applications (although that was the most common scenario), we couldn't implement a single caching strategy that would satisfy everyone. Even in the case of applications running within a web server, there are at least two (or even all three) caching options — in-memory caching and using something like Memcached or Redis. However, there are also CLI (command-line interface) applications, and the caching strategies that work well for web applications are not suitable for command-line ones.
As a result, we decided not to implement a single caching strategy, but to create our own interface listing methods for retrieving and storing data in the cache. We also wrote implementations of this interface for various caching strategies. This way, users of our library (other developers) could either use one of the implementations provided with the library or write their own custom implementation of the caching interface for their needs.
Thus, the situation arose where the interface implementation and its application were separated into different codebases: the implementations were in "our" library, while the application of the interface was in other developers' applications. Our task was to check that our own implementations were indeed correct implementations of our own interface.
Let's assume we have the cache.Interface
interface and the cache.InMemory
and cache.OnDisk
types:
package cache
import (
"encoding/json"
"fmt"
"os"
"sync"
)
type Interface interface {
Get(key string) (value []byte, ok bool)
Set(key string, value []byte)
Delete(key string)
}
type InMemory struct {
mu sync.Mutex
items map[string][]byte
}
func NewInMemory() *InMemory {
return &InMemory{
items: make(map[string][]byte),
}
}
func (c *InMemory) Get(key string) (value []byte, ok bool) {
c.mu.Lock()
value, ok = c.items[key]
c.mu.Unlock()
return value, ok
}
func (c *InMemory) Set(key string, value []byte) {
c.mu.Lock()
c.items[key] = value
c.mu.Unlock()
}
func (c *InMemory) Delete(key string) {
c.mu.Lock()
delete(c.items, key)
c.mu.Unlock()
}
type OnDisk struct {
mu sync.Mutex
items map[string][]byte
filename string
}
func NewOnDisk(filename string) *OnDisk {
return &OnDisk{
items: make(map[string][]byte),
filename: filename,
}
}
func (c *OnDisk) Get(key string) (value []byte, err error) {
c.mu.Lock()
defer c.mu.Unlock()
f, err := os.Open(c.filename)
if err != nil {
return nil, err
}
defer f.Close()
dec := json.NewDecoder(f)
if err := dec.Decode(&c.items); err != nil {
return nil, err
}
value, ok := c.items[key]
if !ok {
return nil, fmt.Errorf("no value for key: %s", key)
}
return value, nil
}
func (c *OnDisk) Set(key string, value []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
f, err := os.Create(c.filename)
if err != nil {
return err
}
enc := json.NewEncoder(f)
if err := enc.Encode(c.items); err != nil {
return err
}
return nil
}
func (c *OnDisk) Delete(key string) error {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, key)
f, err := os.Create(c.filename)
if err != nil {
return err
}
enc := json.NewEncoder(f)
if err := enc.Encode(c.items); err != nil {
return err
}
return nil
}
Now we need to make sure that both of our types, cache.InMemory
and cache.OnDisk
, implement the cache.Interface. How can we achieve this? The first answer that comes to mind is to write a test.
Let's write two small tests to check that our types cache.InMemory
and cache.OnDisk
implement the cache.Interface
:
package cache
import "testing"
func TestInMemoryImplementsInterface(t *testing.T) {
var v interface{} = NewInMemory()
_, ok := v.(Interface)
if !ok {
t.Error("InMemory does not implement Interface")
}
}
func TestOnDiskImplementsInterface(t *testing.T) {
var v interface{} = NewOnDisk("cache.json")
_, ok := v.(Interface)
if !ok {
t.Error("OnDisk does not implement Interface")
}
}
Let’s run these tests:
go test -v ./cache
=== RUN TestInMemoryImplementsInterface
--- PASS: TestInMemoryImplementsInterface (0.00s)
=== RUN TestOnDiskImplementsInterface
cache_test.go:17: OnDisk does not implement Interface
--- FAIL: TestOnDiskImplementsInterface (0.00s)
FAIL
FAIL cache 0.002s
FAIL
As seen from the test results, the cache.InMemory
type implements the cache.Interface
, but the cache.OnDisk
type does not.
While using tests to check for interface implementation works, it does require a certain level of discipline from the developer. You need to remember to write the tests and, just as importantly, to run them periodically.
Fortunately, there is a simpler way to check whether a specific type implements the required interface. You only need to write a single line of code (for us, two lines, since we have two types) and run go build
.
package cache
// ...
var _ Interface = (*InMemory)(nil)
var _ Interface = (*OnDisk)(nil)
go build ./cache
cache/cache.go:6:19: cannot use (*OnDisk)(nil) (value of type *OnDisk) as type Interface in variable declaration:
*OnDisk does not implement Interface (wrong type for Delete method)
have Delete(key string) error
want Delete(key string)
As you can see, the Golang compiler not only informs us that a type does not implement the interface, but also provides insight into the reason for this. In our case, it’s due to different method signatures.
There’s no magic. The underscore symbol (_
) is a special variable name used when we need to assign a value but do not intend to use it later. One of the most common uses of such variables is to ignore errors, for example:
f, _ := os.Open("/path/to/file")
In the above example, we open a file but do not check for potential errors.
Thus, we create an unused variable of type cache.Interface
and assign it a nil pointer to the implementation type (cache.InMemory
or cache.OnDisk
).
In this article, we explored the concept of an "interface" in different programming languages. We determined whether Go is an object-oriented language and learned how to check if a type implements an interface both via tests and during the compilation stage.
On our app platform you can deploy Golang apps, such as Beego and Gin.