06 Struct and Interface What Features Do Structs and Interfaces Implement

06 struct and interface - What Features Do Structs and Interfaces Implement #

In the previous lesson, I left a question for you to think about: can a method be assigned to a variable? If so, how can you call it? The answer is yes, a method can be assigned to a variable, which is called a method expression. The code below demonstrates this:

age := Age(25)

// Method assigned to variable, method expression
sm := Age.String

// Call it using the variable, with an instance passed as the receiver (age)
sm(age)

As we know, the String method doesn’t take any parameters, but after assigning it to the variable sm using a method expression, when calling it, we must pass an instance as the receiver for sm to know how to call it.

Tip: Whether a method has parameters or not, when calling it using a method expression, the first parameter must be the receiver, followed by the method’s own parameters.

Now let’s start today’s lesson. The types we discussed before, such as integers and strings, can only describe individual objects. If we have an aggregate object, we cannot describe it using those types. For example, a person with their name, age, and gender. To describe a person, we need to use structures, which will be covered in this lesson.

Structures #

Structure Definition #

A structure is an aggregate type that can contain values of any type. These values are the members or fields of the structure. In Go, to define a structure, we need to use the type and struct keywords together.

In the example below, I have defined a structure type named person, which represents a person. This person structure has two fields: name, which represents the person’s name, and age, which represents their age.

ch06/main.go

type person struct {
    name string
    age  uint
}

When defining a structure, the declaration of fields is similar to declaring a variable, with the variable name first and the type last. However, in a structure, the variable name is the member or field name.

A structure’s fields are not required; there can be no fields, resulting in an empty structure.

Based on the above information, we can summarize the expression for defining a structure as shown in the code below:

type structName struct {
    fieldName typeName
    ....
    ....
}

Where:

  • type and struct are keywords in Go. When combined, they represent the definition of a new structure type.
  • structName is the name of the structure type.
  • fieldName is the field name in the structure, and typeName is the corresponding field type.
  • There can be zero, one, or more fields.

Tip: Structures are also a type, so from now on, when I say “a user-defined structure,” I mean a user-defined structure or type. For example, the person structure and the person type are the same thing.

Once a structure is defined, it can be used because it is an aggregate type capable of carrying more data than simple types.

Structure Declaration and Usage #

Structure types can be declared and initialized in the same way as string and integer types.

In the example below, I declare a variable p of type person. Since I didn’t initialize the variable p, it will default to zero values for the structure’s fields.

var p person

Of course, when declaring a structure variable, you can also initialize it using a structure literal, as shown in the code below:

p := person{"飞雪无情", 30}

This uses the short declaration syntax and initializes the name field of the structure variable p to “飞雪无情” and the age field to 30, separated by commas.

Once a structure variable is declared, it can be used. Let’s run the code below to check if the name and age values match the initialization values.

fmt.Println(p.name, p.age)

In Go, accessing a structure’s fields is similar to calling a method of a type; both use the dot operator (.).

When initializing a structure using a literal, the order of the initialization values is important; it must match the order of the field definitions in the structure.

In the person structure, the first field is name, which is of type string, and the second field is age, which is of type uint. Therefore, during initialization, the order of the initialization values must correspond one-to-one with the field types defined in the structure. In the example { "飞雪无情", 30 }, the string “飞雪无情” representing the name must come first, followed by the number 30 representing the age.

You may wonder if it’s possible to initialize the fields in a different order. Yes, it is possible, but you need to specify the field names as well, as shown below:

p := person{age: 30, name: "飞雪无情"}

In the above example, I put the age field in the first position, and it still compiles successfully because explicit field:value notation is used to specify the initialization, allowing the Go compiler to clearly understand which field’s value you want to initialize.

Do you notice the similarity between this syntax and initializing a map type using key-value pairs? Both use a colon (:) as the separator. Go attempts to reuse existing expressions to make them easy to remember and use.

It is also possible to initialize only the age field, leaving the name field with its zero value. The code below would still compile successfully:

p := person{age: 30}

Nested Structures #

The fields of a struct can be of any type, including custom struct types. For example, in the code below:

ch06/main.go

type person struct {
    name string
    age uint
    addr address
}

type address struct {
    province string
    city string
}

In this example, I defined two structs: person represents a person, and address represents an address. In the struct person, there is a field addr of type address, which is a custom struct.

By doing this, it is more fitting to describe real-world entities using code and allows for better reuse. For struct types with nested struct fields, the initialization is similar to regular structs. You just need to initialize based on the corresponding field types, as shown in the code below:

ch06/main.go

p := person{
    age: 30,
    name: "飞雪无情",
    addr: address{
        province: "北京",
        city: "北京",
    },
}

If you need to access the value of the innermost field “province” in the struct, you can still use the dot operator, but you need to use two dots, as shown in the code below:

ch06/main.go

fmt.Println(p.addr.province)

The first dot accesses “addr”, and the second dot accesses “province” inside “addr”.

Interfaces #

Definition of Interfaces #

An interface is a contract with the caller. It is a highly abstract type that is not bound to specific implementation details. The interface defines the contract, telling the caller what it can do without needing to know the internal implementation details, which is different from concrete types like int, map, slice, etc., that we usually see.

The definition of an interface is a bit different from a struct, although they both start with the “type” keyword. The keyword for interfaces is “interface”, indicating that the custom type is an interface. In other words, Stringer is an interface with a method String() string. The complete definition looks like the code below:

src/fmt/print.go

type Stringer interface {
    String() string
}

Note: Stringer is an interface in the Go SDK and belongs to the fmt package.

For the Stringer interface, it tells the caller that it can get a string through its String() method. However, the interface doesn’t care about how to obtain this string or what it looks like. These are the responsibilities of the interface implementers.

Implementation of Interfaces #

The implementer of an interface must be a concrete type. Using the person struct as an example again, let it implement the Stringer interface as shown in the code below:

ch06/main.go

func (p person) String() string {
    return fmt.Sprintf("the name is %s,age is %d", p.name, p.age)
}

Define a method for the person struct type. This method has the same signature (name, parameters, and return values) as the method in the interface. This way, the person struct implements the Stringer interface.

Note: If an interface has multiple methods, the implementer needs to implement every method to fully implement the interface.

After implementing the Stringer interface, it can be used. First, let’s define a function that can print a Stringer interface as shown below:

ch06/main.go

func printString(s fmt.Stringer) {
    fmt.Println(s.String())
}

This function printString is defined to receive a parameter of type Stringer interface and then print the string returned by the String method of the Stringer interface.

The advantage of this function printString is that it is programmed against the interface. Any type that implements the Stringer interface can be printed with the corresponding string, regardless of the specific type implementation.

Since the person struct implements the Stringer interface, the variable p can be used as an argument for the function printString and printed in the following way:

printString(p)

The result is:

the name is 飞雪无情,age is 30

Now let’s make the address struct also implement the Stringer interface, as shown in the code below:

ch06/main.go

package main

import (
	"fmt"
	"io"
	"os"
)

func main() {
	f, err := os.Open("test.txt")
	if err != nil {
		fmt.Println("Failed to open file:", err)
		return
	}
	defer f.Close()

	buf := make([]byte, 1024)
	n, err := f.Read(buf)
	if err != nil {
		fmt.Println("Failed to read file:", err)
		return
	}

	fmt.Println("Read", n, "bytes:", string(buf[:n]))
}

在这个示例中,我们使用了os.Open函数打开了一个文件,并通过接口io.Reader实现了文件的读操作。可以看到,通过将文件打开后返回的*os.File类型赋值给了接口类型io.Reader的变量f,然后通过调用f.Read方法实现了文件的读取操作。

这里涉及到了结构体(*os.File)和接口(io.Reader)之间的组合关系。通过将*os.File类型赋值给io.Reader类型的变量,就可以实现对该结构体的函数的调用。

这就是组合的作用,将不同的结构体和接口组合在一起,实现不同的功能。通过接口实现的统一的规范,可以方便地在不同的结构体之间切换和复用代码。

需要注意的是,结构体和接口之间的组合是通过将接口类型作为结构体类型的字段或者返回值来实现的。这样就可以在结构体中使用接口的方法,从而达到代码复用的目的。

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// ReadWriter is a combination of Reader and Writer
type ReadWriter interface {
    Reader
    Writer
}

The ReadWriter interface is a combination of the Reader and Writer interfaces. By combining them, the ReadWriter interface includes all the methods from Reader and Writer, so there’s no need to define additional methods for the new ReadWriter interface.

Not only interfaces can be combined, but also structs can be combined. Now let’s combine the address struct into the person struct, not as a field, as shown below:

type person struct {
    name    string
    age     uint
    address
}

By directly including the struct type, we achieve composition. There is no need for a field name for the composed struct. After combining, the composed address is called the inner type and person is called the outer type. After modifying the person struct, the declaration and usage also need to be modified accordingly, as shown below:

p := person{
    age: 30,
    name: "飞雪无情",
    address: address{
        province: "北京",
        city:     "北京",
    },
}
// Just like using its own fields, we can use it directly
fmt.Println(p.province)

Because the person struct includes the address struct, the fields of address can be accessed directly as if they were the person’s own fields.

After types are composed, the outer type not only can access the fields of the inner type, but also can access the methods of the inner type just like its own methods. If the outer type defines a method with the same name as a method in the inner type, the method in the outer type will override the method in the inner type. This is called method overriding. I won’t provide an example of method overriding here, but you can try it yourself.

Tip: Method overriding does not affect the method implementation in the inner type.

Type Assertion #

With interfaces and types implementing those interfaces, we can perform type assertions. Type assertions are used to determine if the value of an interface is of a certain concrete type that implements that interface.

Let’s recall the examples from the previous section. We defined the following methods:

func (p *person) String() string {
    return fmt.Sprintf("the name is %s,age is %d", p.name, p.age)
}

func (addr address) String() string {
    return fmt.Sprintf("the addr is %s%s", addr.province, addr.city)
}

Both *person and address implement the Stringer interface. Now let’s demonstrate type assertions with the following example:

var s fmt.Stringer
s = p1
p2 := s.(*person)
fmt.Println(p2)

In the example above, the interface variable s is of type fmt.Stringer and is assigned the value p1. Then, a type assertion expression s.(*person) is used to attempt to assign a value to p2. If the value of the interface s is a *person, the type assertion is successful and p2 is assigned the value p2. If the value of the interface s is not a *person, a runtime exception will occur and the program will terminate.

Tip: The resulting value p2 is already of type *person. In other words, the type conversion is also performed during the type assertion.

In the above example, since s is indeed a *person, the type assertion is successful and p2 can be printed. However, if I add the following code to perform an address type assertion on s, some problems will arise:

a := s.(address)
fmt.Println(a)

The code will compile without any issues, since address implements the Stringer interface. However, at runtime, the following exception will occur:

panic: interface conversion: fmt.Stringer is *main.person, not main.address

This is clearly not what we intended. We wanted to check if the value of an interface is a specific type, but we don’t want the program to terminate if the check fails. To address this, Go provides multiple return values for type assertions, as shown below:

a, ok := s.(address)
if ok {
    fmt.Println(a)
} else {
    fmt.Println("s is not an address")
}

The second value returned by the type assertion expression “ok” is a flag indicating whether the assertion was successful. If it’s true, the assertion was successful, otherwise it was unsuccessful.

Summary #

Although this section only covered structs and interfaces, there were many concepts involved. The section is quite extensive, so please be patient and go through it thoroughly.

Structs describe real-world entities, and interfaces specify behaviors and provide abstraction for a certain type of entity. With structs and interfaces, we can achieve code abstraction and reuse, and we can also write code in a way that’s more flexible and adaptable by programming against interfaces, hiding the specific implementation details.