How come reflection is the only way to DRY this code, and will something be done about it?

With foundational background in dynamically typed programming languages, I am struggling to come to terms with static typing.

Static typing itself would have been fine if it wasn’t causing the need to either violate the DRY principle, or use performance-costly reflection in certain situations. Like in this one:

Say we need to write a video stream parser. Video binary data is comprised of frames, each having a type, a certain header structure and a payload. My CCTV cameras produce streams of 5 frame types (video key, video non-key, audio, still picture and info), but for simplicity let’s consider only 2, simplified types. Full example with some fake data:

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

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
}

type HeaderAudio struct {
    SampleRate  uint16
    Length      uint16
}

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

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])
        frame := TMap[cHeader.Type]()
        binary.Read(data, binary.LittleEndian, frame)
        fmt.Println(frame)
        payload := make([]byte, frame.GetLength())
        io.ReadFull(data, payload)
        fmt.Println(payload)
    }
}

See — exactly the same implementation of the GetLength() method has to be repeated for each frame type. Not a big deal I hear you say. Indeed, in this case. Even if repeated 5 times in the real code (5 frame types). But the fact remains: the code cannot be DRYed in principle. Unless using reflection:

func GetLength(frame any) int {
    return int(reflect.ValueOf(frame).Elem().FieldByName("Length").Uint())
}

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

So, I am just wondering: how do the current authors of Go see this problem? Is it recognised? Will it be tackled at some point? Or do they not see it a problem at all?

I don’t think it’s an actual problem, but a misunderstanding. Since you’ve used the interface:

type Header interface {
	GetLength() int
}

that means every frame struct e.g., HeaderVideo, HeaderAudio must implement this method to implement the interface. Two different structs, with two different Length values of not matching type. Imo it’s perfectly fine to leave it as it is now.

If they would’ve have the same type, I could advise embedding. Something like:

type Length struct {
	Length uint32
}

func (l Length) GetLength() int {
	return int(l.Length)
}

type HeaderVideo struct {
	Width  uint16
	Height uint16
	Length
}

type HeaderAudio struct {
	SampleRate uint16
	Length
}

But this requires to change the bytes stream and creates another nesting level for the value.

P.S. imho knowing how many times you need to write return error check, I don’t think go can be DRY-ed completely. Sometimes repetitions of the code do not imply that you actually repeat yourself.

Even though the structs are different, the method implementation does not care: it is always just int(h.Length). There isn’t any way to reuse it (apart from reflection).

Exactly. The byte stream is the program input and can’t be changed. We form the structs to take its shape to employ the convenience of binary.Read.

True — we repeat the operators after all :laughing:. However, this particular repetition of GetLength() implementation is exactly that kind of repetition that in many languages is avoidable (by inheritance, or composition that is not obstructed by type differences).

It’s true, but that’s how go works. If you use interface, every type should have this method if it wants to implement this interface. Go gives embedding or actual “write code solution” for this matter or reflect. But reflection is something I try to avoid as much as possible. Maybe, it will be different when we have generic methods.

And you are 100% right here. But imo it’s exactly one of the main differences of how static and dynamic types work.

Huh, it turns out generics can actually be used to DRY the above code, as in this Stackoverflow answer. Yay!

2 Likes