Search code examples
godry

How to avoid code repetition where types need to be chosen dynamically?


The following code is a simplified example of a video stream parser. The input is binary data containing of video and audio frames. Each frame is comprised of:

  1. Frame type flag indicating whether it is video or audio frame
  2. Header
  3. Payload

The goal is to parse the stream, extract fields from the headers and the payload.

So, the first approach is:

package main
import (
    "fmt"
    "encoding/binary"
    "bytes"
)

type Type byte

const (
    Video  Type = 0xFC
    Audio   Type = 0xFA
)

var HMap = map[Type]string {
    Video:   "Video",
    Audio:   "Audio",
}

type CommonHeader struct {
    Type      Type
}

type HeaderVideo struct {
    Width       uint16
    Height      uint16
    Length      uint32
}

type HeaderAudio struct {
    SampleRate  uint16
    Length      uint16
}


func main() {
    data := bytes.NewReader([]byte{0xFC, 0x80, 0x07, 0x38, 0x04, 0x02, 0x00, 0x00, 0x00, 0xFF, 0xAF, 0xFA, 0x10, 0x00, 0x01, 0x00, 0xFF})
    var cHeader CommonHeader
    var dataLength int
    for {
        err := binary.Read(data, binary.LittleEndian, &cHeader)
        if err != nil {
            break
        }
        fmt.Println(HMap[cHeader.Type])
        switch cHeader.Type {
            case Video:
                var info HeaderVideo
                binary.Read(data, binary.LittleEndian, &info)
                dataLength = int(info.Length)
                fmt.Println(info)
            case Audio:
                var info HeaderAudio
                binary.Read(data, binary.LittleEndian, &info)
                dataLength = int(info.Length)
                fmt.Println(info)
        }
        payload := make([]byte, dataLength)
        data.Read(payload)
        fmt.Println(payload)
    }
}

It works, but I don't like the code repetition in the switch cases. Essentially, we have to repeat the same code just because the frame types are different.

One approach trying to avoid the repetition is this:

package main
import (
    "fmt"
    "encoding/binary"
    "bytes"
)

type Type byte

const (
    Video  Type = 0xFC
    Audio   Type = 0xFA
)

var HMap = map[Type]string {
    Video:   "Video",
    Audio:   "Audio",
}

type CommonHeader struct {
    Type      Type
}

type Header interface {
    GetLength() int
}

type HeaderVideo struct {
    Width       uint16
    Height      uint16
    Length      uint32
}

func (h HeaderVideo) GetLength() int {
    return int(h.Length)
}

type HeaderAudio struct {
    SampleRate  uint16
    Length      uint16
}

func (h HeaderAudio) GetLength() int {
    return int(h.Length)
}

var TMap = map[Type]func() Header {
    Video:     func() Header { return &HeaderVideo{} },
    Audio:     func() Header { return &HeaderAudio{} },
}

func main() {
    data := bytes.NewReader([]byte{0xFC, 0x80, 0x07, 0x38, 0x04, 0x02, 0x00, 0x00, 0x00, 0xFF, 0xAF, 0xFA, 0x10, 0x00, 0x01, 0x00, 0xFF})
    var cHeader CommonHeader
    for {
        err := binary.Read(data, binary.LittleEndian, &cHeader)
        if err != nil {
            break
        }
        fmt.Println(HMap[cHeader.Type])
        info := TMap[cHeader.Type]()
        binary.Read(data, binary.LittleEndian, info)
        fmt.Println(info)
        payload := make([]byte, info.GetLength())
        data.Read(payload)
        fmt.Println(payload)
    }
}

That is, we implement dynamic type selection by introducing the TMap map which allows to create an instance of the right struct depending on the frame type. However, this solution comes at the cost of repeating the GetLength() method — for each of the frame types.

I find it quite disconcerting that there doesn't seem to be a way to avoid repetition completely. Am I missing some way, or is it just a limitation of the language?

Here is a related question (which was actually triggered by the same problem), however, its premise omits the need for dynamic type selection, and thus the accepted solution (using generics) does not help.


Solution

  • The King's answer requires duplication for each integer type used to encode the length. Mondarin's answer uses the dreaded reflect package. Here's a solution that avoids both problems. This answer is based on the King's answer.

    Declare a generic type with the GetLength() method.

    type Length[T uint8 | uint16 | uint32 | uint64] struct { Length T }
    
    func (l Length[T]) GetLength() int { return int(l.Length) }
    

    Remove the GetLength method from each header type. Embed the generic length type in each header type:

    type HeaderVideo struct {
        Width  uint16
        Height uint16
        Length[uint32]
    }
    
    type HeaderAudio struct {
        SampleRate uint16
        Length[uint16]
    }
    

    Declare TMap as in the question. The GetLength method is provided by embedded field.

    var TMap = map[Type]func() Header{
        Video: func() Header { return &HeaderVideo{} },
        Audio: func() Header { return &HeaderAudio{} },
    }
    

    https://go.dev/play/p/H2gWStsouly

    (Like the code in the question, this answer uses the reflect package indirectly through the binary.Read function. The reflect package is a great tool for keeping code DRY.)