Search code examples
performancegoreflectiongo-reflect

Can I optimize this Go reflect function so it isn't as slow?


I am working on a project where I need to build out the partial updates for all of the methods that will be supported. Each partial update will require a different struct, with different fields and number of fields, and not knowing which ones will be present or not. I decided on going over each struct field, and if it's present adding it to an array to return at the end. I also took some time to Benchmark a couple of functions that seemed most realistic to approach this problem, and to help make a decision.

All of the structs fields will be pointers. With that in mind, these are the functions I wrote.

Note: I can't create a playground example for this, because it doesn't support Benchmarks. I'll link the full classes, and put the explanation above them.

  1. Create a mapping function for each partial update struct, where I would check each field separately. If the field is not nil, I will put the value in a 2D array storing in a [key,value] format. After the struct has been processed, return the array.
  2. Create a single mapping function that uses Generics and Reflection to do the same as above.
// main.go
package main

import (
    "reflect"
    "strings"
    "time"
)

type updateRequest struct {
    FieldOne   *string    `json:"field_one,omitempty"`
    FieldTwo   *string    `json:"field_two,omitempty"`
    FieldThree *string    `json:"field_three,omitempty"`
    FieldFour  *string    `json:"field_four,omitempty"`
    FieldFive  *string    `json:"field_five,omitempty"`
    FieldSix   *time.Time `json:"field_six,omitempty" time_format:"2006-01-02"`
}

// Mapping function that would need to be recreated for each partial update struct.
func ManualUpdateRequestMapping(req *updateRequest) [][]string {
    vals := make([][]string, 0, 6)
    if req.FieldOne != nil {
        vals = append(vals, []string{"field_one", *req.FieldOne})
    }

    if req.FieldTwo != nil && req.FieldThree != nil {
        vals = append(vals, []string{"field_two", *req.FieldTwo}, []string{"field_three", *req.FieldThree})
    }

    if req.FieldFour != nil {
        vals = append(vals, []string{"field_four", *req.FieldFour})
    }

    if req.FieldFive != nil {
        vals = append(vals, []string{"field_five", *req.FieldFive})
    }

    if req.FieldSix != nil {
        vals = append(vals, []string{"field_six", req.FieldSix.Format(time.RFC3339)})
    }

    return vals
}

// Generics and Reflection function
func ReflectUpdateRequestMapping[T *updateRequest](str T) [][]string {
    valHolder := reflect.ValueOf(*str)
    if valHolder.Kind() != reflect.Struct {
        return nil
    }
    vals := make([][]string, 0, valHolder.NumField())
    for i := 0; i < valHolder.NumField(); i++ {
        if valHolder.Field(i).IsNil() {
            continue
        }
        spl := strings.Split(valHolder.Type().Field(i).Tag.Get("json"), ",")

        if valHolder.Field(i).Elem().Type() != reflect.TypeOf(time.Time{}) {
            vals = append(vals, []string{spl[0], valHolder.Field(i).Elem().String()})
        } else {
            vals = append(vals, []string{spl[0], valHolder.Field(i).Interface().(*time.Time).Format(time.RFC3339)})
        }
    }
    return vals
}

This is the benchmark method I ran with:

// main_test.go
package main

import (
    "testing"
    "time"
)

func BenchmarkBoth(b *testing.B) {

    field1 := "testfield1"
    field2 := "testfield2"
    field3 := "testfield3"
    field4 := "testfield4"
    field5 := "testfield5"
    date1, _ := time.Parse(time.RFC3339, "2004-10-16T12:40:53.00Z")

    str := &updateRequest{
        FieldOne:   &field1,
        FieldTwo:   &field2,
        FieldThree: &field3,
        FieldFour:  &field4,
        FieldFive:  &field5,
        FieldSix:   &date1,
    }
    b.Run("ManualUpdateRequestMapping", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = ManualUpdateRequestMapping(str)
        }
    })

    b.Run("ReflectUpdateRequestMapping", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = ReflectUpdateRequestMapping(str)
        }
    })
}

Below is the CPU used and the results that come from the test:

cpu: 12th Gen Intel(R) Core(TM) i9-12900KF
BenchmarkBoth/ManualUpdateRequestMapping-24              3560083           331.9 ns/op       368 B/op          8 allocs/op
BenchmarkBoth/ReflectUpdateRequestMapping-24             1393377           866.7 ns/op       648 B/op         21 allocs/op
PASS
ok      com.example.stack   3.745s

I expected the Reflection function to be slower, but not ~2.5x slower. It also seems to allocate ~2.5x more resources per iteration. Did I botch something in the code above, or is Reflection just that much slower?

If there are any recommendations to make the code above more efficient, I am open to all suggestions. I've been working with Go for about 3 months now, so please go easy on me if I committed any Golang treason in the code above. :)


Solution

  • Reflection is slower than non-reflection code. Here's an improvement. Some notes:

    • Reduce reflect calls by getting the field as a regular typed value and working from there.
    • There's no need for that new-fangled type parameter stuff.
    • Speaking of Golang treason, the name of the language is Go.

    With that out of the way, here's the code:

    func UpdateRequestMapping(p any) [][]string {
        v := reflect.ValueOf(p).Elem()
        if v.Kind() != reflect.Struct {
            return nil
        }
        t := v.Type()
        result := make([][]string, t.NumField())
        for i := 0; i < t.NumField(); i++ {
            var ok bool
            var value string
            switch f := v.Field(i).Interface().(type) {
            case *string:
                if f != nil {
                    ok = true
                    value = *f
                }
            case *time.Time:
                if f != nil {
                    ok = true
                    value = (*f).Format(time.RFC3339)
                }
            }
            if ok {
                name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
                result[i] = []string{name, value}
            }
        }
        return result
    }