Can Go, a programming language known as “Golang,” be considered object-oriented? While Go is classified as a post-OOP language, drawing its structure from the Algol/Pascal/Modula family, object-oriented principles remain valuable for organizing programs effectively. This tutorial explores how to implement OOP concepts in Go, including binding functions to types (similar to classes), constructors, subtyping, polymorphism, dependency injection, and testing with mocks, using a practical example.
Deciphering Vehicle Identification Numbers (VINs) with Golang OOP
Each car’s unique vehicle identification number holds information beyond a serial number, including details about the manufacturer, factory, model, and steering wheel position.
Consider a function designed to extract the manufacturer code:
1
2
3
4
5
6
7
8
9
10
11
12
13
| package vin
func Manufacturer(vin string) string {
manufacturer := vin[: 3]
// if the last digit of the manufacturer ID is a 9
// the digits 12 to 14 are the second part of the ID
if manufacturer[2] == '9' {
manufacturer += vin[11: 14]
}
return manufacturer
}
|
A test case confirms its functionality:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| package vin_test
import (
"vin-stages/1"
"testing"
)
const testVIN = "W09000051T2123456"
func TestVIN_Manufacturer(t *testing.T) {
manufacturer := vin.Manufacturer(testVIN)
if manufacturer != "W09123" {
t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
}
}
|
Despite working correctly with valid input, this function presents several issues:
- Lack of input validation to ensure the provided string is a VIN.
- Potential
panic for strings shorter than three characters. - Inaccuracy for US car VINs due to the function assuming an optional second part of the ID, a feature specific to European VINs.
These problems can be addressed through refactoring using object-oriented patterns.
Go OOP: Associating Functions with Types
The initial refactoring step involves creating a dedicated VIN type and binding the Manufacturer() function to it, enhancing clarity and preventing unintended usage.
1
2
3
4
5
6
7
8
9
10
11
12
13
| package vin
type VIN string
func (v VIN) Manufacturer() string {
manufacturer := v[: 3]
if manufacturer[2] == '9' {
manufacturer += v[11: 14]
}
return string(manufacturer)
}
|
The test case is then adjusted to accommodate the new type and demonstrate the issue of invalid VINs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| package vin_test
import(
"vin-stages/2"
"testing"
)
const (
validVIN = vin.VIN("W0L000051T2123456")
invalidVIN = vin.VIN("W0")
)
func TestVIN_Manufacturer(t * testing.T) {
manufacturer := validVIN.Manufacturer()
if manufacturer != "W0L" {
t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, validVIN)
}
invalidVIN.Manufacturer() // panic!
}
|
The inclusion of the last line showcases how a panic can occur with the Manufacturer() function, potentially leading to program crashes outside a testing environment.
Implementing Constructors in Golang OOP
To prevent panic situations caused by invalid VINs, one option is to incorporate validity checks directly within the Manufacturer() function. However, this approach necessitates checks with every function call and introduces error handling that complicates direct usage of the return value.
A more elegant solution is to implement a constructor for the VIN type, ensuring that the Manufacturer() function is invoked solely for valid VINs, eliminating the need for redundant checks and error management.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| package vin
import "fmt"
type VIN string
// it is debatable if this func should be named New or NewVIN
// but NewVIN is better for greping and leaves room for other
// NewXY funcs in the same package
func NewVIN(code string)(VIN, error) {
if len(code) != 17 {
return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code)
}
// ... check for disallowed characters ...
return VIN(code), nil
}
func (v VIN) Manufacturer() string {
manufacturer := v[: 3]
if manufacturer[2] == '9' {
manufacturer += v[11: 14]
}
return string(manufacturer)
}
|
A corresponding test for the NewVIN function is added, verifying the rejection of invalid VINs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| package vin_test
import (
"vin-stages/3"
"testing"
)
const (
validVIN = "W0L000051T2123456"
invalidVIN = "W0"
)
func TestVIN_New(t *testing.T) {
_, err := vin.NewVIN(validVIN)
if err != nil {
t.Errorf("creating valid VIN returned an error: %s", err.Error())
}
_, err = vin.NewVIN(invalidVIN)
if err == nil {
t.Error("creating invalid VIN did not return an error")
}
}
func TestVIN_Manufacturer(t *testing.T) {
testVIN, _ := vin.NewVIN(validVIN)
manufacturer := testVIN.Manufacturer()
if manufacturer != "W0L" {
t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
}
}
|
With the constructor in place, the test for the Manufacturer() function can omit testing invalid VINs.
Go OOP Pitfall: Misusing Polymorphism
To differentiate between European and non-European VINs, one approach could involve extending the VIN type into a struct and storing a flag indicating its origin, modifying the constructor accordingly:
1
2
3
4
5
6
7
8
9
10
11
| type VIN struct {
code string
european bool
}
func NewVIN(code string, european bool)(*VIN, error) {
// ... checks ...
return &VIN { code, european }, nil
}
|
A more refined approach is to create a subtype of VIN specifically for European VINs, implicitly storing the type information and simplifying the Manufacturer() function for non-European VINs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| package vin
import "fmt"
type VIN string
func NewVIN(code string)(VIN, error) {
if len(code) != 17 {
return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code)
}
// ... check for disallowed characters ...
return VIN(code), nil
}
func (v VIN) Manufacturer() string {
return string(v[: 3])
}
type EUVIN VIN
func NewEUVIN(code string)(EUVIN, error) {
// call super constructor
v, err := NewVIN(code)
// and cast to subtype
return EUVIN(v), err
}
func (v EUVIN) Manufacturer() string {
// call manufacturer on supertype
manufacturer := VIN(v).Manufacturer()
// add EU specific postfix if appropriate
if manufacturer[2] == '9' {
manufacturer += string(v[11: 14])
}
return manufacturer
}
|
In OOP languages like Java, the EUVIN subtype would seamlessly work wherever the VIN type is expected. However, this behavior differs in Golang OOP.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| package vin_test
import (
"vin-stages/4"
"testing"
)
const euSmallVIN = "W09000051T2123456"
// this works!
func TestVIN_EU_SmallManufacturer(t *testing.T) {
testVIN, _ := vin.NewEUVIN(euSmallVIN)
manufacturer := testVIN.Manufacturer()
if manufacturer != "W09123" {
t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
}
}
// this fails with an error
func TestVIN_EU_SmallManufacturer_Polymorphism(t *testing.T) {
var testVINs[] vin.VIN
testVIN, _ := vin.NewEUVIN(euSmallVIN)
// having to cast testVIN already hints something is odd
testVINs = append(testVINs, vin.VIN(testVIN))
for _, vin := range testVINs {
manufacturer := vin.Manufacturer()
if manufacturer != "W09123" {
t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
}
}
}
|
This discrepancy arises from Go’s design choice to prioritize compile-time function resolution over dynamic binding for non-interface types, promoting efficiency but discouraging inheritance as a primary composition pattern in favor of interfaces.
Golang OOP Success: Employing Polymorphism Effectively
Go identifies a type as an interface implementation if it implements the defined functions (duck typing). To leverage polymorphism, the VIN type is transformed into an interface implemented by both general and European VIN types. It’s worth noting that the European VIN type need not be a subtype of the general one.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| package vin
import "fmt"
type VIN interface {
Manufacturer() string
}
type vin string
func NewVIN(code string)(vin, error) {
if len(code) != 17 {
return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code)
}
// ... check for disallowed characters ...
return vin(code), nil
}
func (v vin) Manufacturer() string {
return string(v[: 3])
}
type vinEU vin
func NewEUVIN(code string)(vinEU, error) {
// call super constructor
v, err := NewVIN(code)
// and cast to own type
return vinEU(v), err
}
func (v vinEU) Manufacturer() string {
// call manufacturer on supertype
manufacturer := vin(v).Manufacturer()
// add EU specific postfix if appropriate
if manufacturer[2] == '9' {
manufacturer += string(v[11: 14])
}
return manufacturer
}
|
With a minor adjustment, the polymorphism test now succeeds:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // this works!
func TestVIN_EU_SmallManufacturer_Polymorphism(t *testing.T) {
var testVINs[] vin.VIN
testVIN, _ := vin.NewEUVIN(euSmallVIN)
// now there is no need to cast!
testVINs = append(testVINs, testVIN)
for _, vin := range testVINs {
manufacturer := vin.Manufacturer()
if manufacturer != "W09123" {
t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
}
}
}
|
Both VIN types can now be used interchangeably wherever the VIN interface is specified, ensuring compatibility.
Utilizing Dependency Injection in Object-oriented Golang
To determine a VIN’s origin, let’s assume an external API provides this information, and we have developed a client for it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| package vin
type VINAPIClient struct {
apiURL string
apiKey string
// ... internals go here ...
}
func NewVINAPIClient(apiURL, apiKey string) *VINAPIClient {
return &VINAPIClient {apiURL, apiKey}
}
func (client *VINAPIClient) IsEuropean(code string) bool {
// calls external API and returns correct value
return true
}
|
Additionally, a service handles VIN creation and management:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| package vin
type VINService struct {
client *VINAPIClient
}
type VINServiceConfig struct {
APIURL string
APIKey string
// more configuration values
}
func NewVINService(config *VINServiceConfig) *VINService {
// use config to create the API client
apiClient := NewVINAPIClient(config.APIURL, config.APIKey)
return &VINService {apiClient}
}
func (s *VINService) CreateFromCode(code string)(VIN, error) {
if s.client.IsEuropean(code) {
return NewEUVIN(code)
}
return NewVIN(code)
}
|
This implementation functions correctly, as demonstrated by the modified test:
1
2
3
4
5
6
7
8
9
10
| func TestVIN_EU_SmallManufacturer(t *testing.T) {
service := vin.NewVINService( & vin.VINServiceConfig {})
testVIN, _ := service.CreateFromCode(euSmallVIN)
manufacturer := testVIN.Manufacturer()
if manufacturer != "W09123" {
t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
}
}
|
However, the test relies on a live connection to the external API, introducing potential issues related to availability, latency, and cost.
Since the API call’s result is predetermined, replacing it with a mock becomes desirable. Unfortunately, the current code structure prevents easy replacement as the VINService directly creates the API client. To address this, dependency injection can be employed, whereby the API client is injected into the VINService instead of being internally instantiated.
A key principle in Golang OOP is to avoid constructor chaining. By adhering to this principle, all singletons used within an application are initialized at the top level, typically within a bootstrapping function responsible for creating necessary objects and establishing dependencies.
The first step involves defining the VINAPIClient as an interface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| package vin
type VINAPIClient interface {
IsEuropean(code string) bool
}
type vinAPIClient struct {
apiURL string
apiKey string
// .. internals go here ...
}
func NewVINAPIClient(apiURL, apiKey string) *VINAPIClient {
return &vinAPIClient {apiURL, apiKey}
}
func (client *VINAPIClient) IsEuropean(code string) bool {
// calls external API and returns something more useful
return true
}
|
Subsequently, the client can be injected into the VINService:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| package vin
type VINService struct {
client VINAPIClient
}
type VINServiceConfig struct {
// more configuration values
}
func NewVINService(config *VINServiceConfig, apiClient VINAPIClient) *VINService {
// apiClient is created elsewhere and injected here
return &VINService {apiClient}
}
func (s *VINService) CreateFromCode(code string)(VIN, error) {
if s.client.IsEuropean(code) {
return NewEUVIN(code)
}
return NewVIN(code)
}
|
This modification enables the use of a API client mock during testing, avoiding external API calls and providing insights into API usage through probing. The example below demonstrates how to verify if the IsEuropean function is invoked.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| package vin_test
import (
"vin-stages/5"
"testing"
)
const euSmallVIN = "W09000051T2123456"
type mockAPIClient struct {
apiCalls int
}
func NewMockAPIClient() *mockAPIClient {
return &mockAPIClient {}
}
func (client *mockAPIClient) IsEuropean(code string) bool {
client.apiCalls++
return true
}
func TestVIN_EU_SmallManufacturer(t *testing.T) {
apiClient := NewMockAPIClient()
service := vin.NewVINService( & vin.VINServiceConfig {}, apiClient)
testVIN, _ := service.CreateFromCode(euSmallVIN)
manufacturer := testVIN.Manufacturer()
if manufacturer != "W09123" {
t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
}
if apiClient.apiCalls != 1 {
t.Errorf("unexpected number of API calls: %d", apiClient.apiCalls)
}
}
|
The test passes successfully, confirming the execution of the IsEuropean probe during the CreateFromCode call.
Achieving Success with Object-oriented Programming in Go
While some might question the use of Go for OOP, Go offers distinct advantages such as avoiding resource-intensive VMs/JITs, complex frameworks, and slow test execution times.
The provided example illustrates how object-oriented principles in Go can lead to more comprehensible and efficient code compared to a purely imperative approach. Despite not being strictly an OOP language, Go provides the necessary tools to structure applications in an object-oriented manner. By combining this with package-based functionality grouping, Go enables the creation of reusable modules for building robust applications.
As a Google Cloud Partner, Toptal connects businesses with top-tier, Google-certified experts on demand for their most critical projects.