11 minutes read

Package io provides many useful interfaces. Among them are Reader, Writer, Closer and Seeker. In this topic, you will see where they are used and how you can use it in your projects.

Reader interface

This is how the interface is presented in the source code of the package:

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

This interface specifies a contract that the method reads data (from the struct that fulfills the interface) into the slice of bytes p and returns the n number of bytes read. This number cannot exceed the size of the slice (len(p)). Slice size must be set in advance. If something goes wrong, the method should return an error. If the data is all read, the method also returns an error, but a special one. This is io.EOF (end of file). You must take this into account in your code in order to correctly complete the read operation.

Look at the following example:

package main

import (
    "fmt"
    "io"
    "strings"
)

const chunkSize = 12

func main() {
    reader := strings.NewReader("Supercalifragilisticexpialidocious")
    p := make([]byte, chunkSize)
    for chunkCount := 1; ; chunkCount++ {
        n, err := reader.Read(p)
        switch err {
        case nil:
            fmt.Println(chunkCount, " chunk: ", string(p[:n]))
        case io.EOF:
            fmt.Println("end of file")
            return
        default:
            fmt.Println(err)
            return
        }
    }
}

// Output: 
// 1  chunk:  Supercalifra
// 2  chunk:  gilisticexpi
// 3  chunk:  alidocious  
// end of file

After calling strings.NewReader you get Reader for your purposes. This is strings.Reader type that implements io.Reader interface. In the code, you use it like this: reader.Read(p).

Let's move on. No matter what source of information you work with, with a regular string, with a file or a network connection, you should always have one approach.

In the following source code snippet, you can also see the method Read:

file, err := os.Open("file.txt")
if err != nil {
    return err
}

buf := make([]byte, 1024)
n, err := file.Read(buf)

Variable file is by File type from os package. Remember the rule: "If it looks like a duck, and it quacks like a duck, then it is a duck". In other words, if it reads like an io.Reader, then it is an io.Reader.

Let's check our guesses in the following code:

package main

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

func show(r io.Reader) error {
    buf := make([]byte, 1024)
    if _, err := r.Read(buf); err != nil {
        return err
    }
    
    fmt.Println(string(buf))
    return nil
}

func main() {
    file, _ := os.Open("file.txt")
    if err := show(file); err != nil {
        log.Fatal(err)
    }

    reader := strings.NewReader("hello")
    if err := show(reader); err != nil {
        log.Fatal(err)
    }
}

// file.txt contents: "Hello from file!".
// Output:
// Hello from file!
// hello

It works! os.File and strings.Reader types both implement io.Reader interface.

Writer interface

The io.Writer interface has the following methods:

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

You have the same variables p, n, err for Write method. This interface specifies a contract that the method writes data into the structure that fulfills the Writer interface from the slice p and returns the n number of bytes written. This number cannot exceed the size of the slice (len(p)).

Let's go a little further on this issue. You have Fprintln(...) method in fmt package:

func Fprintln(w io.Writer, a ...any) (n int, err error)

You also have Buffer type in bytes package. It implements io.Writer interface:

func (b *Buffer) Write(p []byte) (n int, err error)

So you can use variables of bytes.Buffer type as input for the Fprintln method. You also can use the method Write for your variable.

Look at the example:

package main

import (
    "bytes"
    "fmt"
)

func main() {
    var b bytes.Buffer

    fmt.Fprintln(&b, "Winnie the Pooh: 'A hug is always the right size'.")
    fmt.Println(b.String())

    b.Write([]byte("Mary Poppins: 'Just a spoonful of sugar helps the medicine go down'."))
    fmt.Println(b.String())
}

// Output:
// Winnie the Pooh: 'A hug is always the right size'.
// 
// Winnie the Pooh: 'A hug is always the right size'.                  
// Mary Poppins: 'Just a spoonful of sugar helps the medicine go down'.

Because Buffer type implements io.Writer interface, you can use variable b as input for fmt.Fprintln, because this method uses Write inside.

Seeker interface

Another useful interface available in the io package is io.Seeker:

type Seeker interface {
    Seek(offset int64, whence int) (int64, error)
}

With the Seek method, you can set the offset for Read or Write methods. Where whence could be SeekStart, SeekCurrent or SeekEnd. The names of the constants speak for themselves (offset from the start, current position, or end of the source).

Look at the following example:

package main

import (
    "fmt"
    "io"
    "strings"
)

func main() {
    quote := "Someplace where there isn't any trouble."
    offset := 16
    reader := strings.NewReader(quote)

    reader.Seek(int64(offset), io.SeekStart)

    b := make([]byte, len(quote)-offset-1)
    reader.Read(b)
    fmt.Println(string(b))
}

// Output:
// there isn't any trouble

With the Seek method, you can place the cursor at a specific position before the reading or writing operation. You are looking for the position of this word in the sentence, and set it as the offset from the beginning of the string with the Seek method. Furthermore, you can calculate the size of the buffer because you know the size of the original string literal and the offset (len(quote)-offset-1). For the beauty of the output, you do not read the last character (dot in our case), so you do -1 for buffer size.

Closer interface

If you write somewhere, you occupy a resource. If this resource is required by others, then it is necessary to release it when it is no longer needed. In this case, it is recommended to use the io.Closer interface:

type Closer interface {
    Close() error
}

Method Close is implementation specific. For example, you can close network protocol or database connection, logger or a specific session.

Example of using the Close method for File type:

file, err := os.Open("input.txt")	
if err != nil {
    return err
}

data, err := io.ReadAll(file)
if err != nil {
    return err
}

file.Close()

Combination of interfaces

Sometimes you need to specify that your type implements several interfaces, for example, it is a Reader and a Writer at the same time. For this purpose, you have these types of interfaces in io package, too.

Let's look at the ReadCloser interface:

type ReadCloser interface {
    Reader
    Closer
}

You use embedding of interfaces, so this notation is similar to:

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

You can find more of these combinations in the io package:

  • ReadCloser

  • WriteCloser

  • ReadSeeker

  • ReadSeekCloser

Conclusion

You should now feel comfortable working with interfaces from the io package. Remember that input and output is not only about files, it can be ordinary string literals, responses from protocols in various formats (http, for example), databases, streams, etc.

13 learners liked this piece of theory. 1 didn't like it. What about you?
Report a typo