diff --git a/cache.go b/cache.go index 065b8d6..3127ca0 100644 --- a/cache.go +++ b/cache.go @@ -37,6 +37,43 @@ func (c *cache) registerConverter(value interface{}, converterFunc Converter) { c.regconv[reflect.TypeOf(value)] = converterFunc } +// pathIter is used to iterate over the dotted notation argument to (*cache).parsePath. +// internally it utilizes strings.Cut to minimize allocations and garbage generated. +// +// this should be used with a for loop as: +// +// var it pathIter +// for key, ok := it.start(path); ok; key, ok = it.advance() {} +type pathIter struct { + rest string + lastFound bool +} + +// start initializes the iteration, and returns the first key. +func (p *pathIter) start(path string) (string, bool) { + // init the iter for the first call to next, so that the first call will either + // cut on "." and return the "before", or it will return path if it does not contain + // ".". + p.rest = path + p.lastFound = true + return p.next() +} + +// advance advances the iteration to the next key. +func (p *pathIter) advance() (string, bool) { + return p.next() +} + +// next should only be used by (*pathIter).start and (*pathIter).advance. it will cut p.rest, +// and return the "before", storing the "after" for the subsequent call. +func (p *pathIter) next() (key string, ok bool) { + var found bool + key, p.rest, found = strings.Cut(p.rest, ".") + ok = found || p.lastFound + p.lastFound = found + return key, ok +} + // parsePath parses a path in dotted notation verifying that it is a valid // path to a struct field. // @@ -48,41 +85,48 @@ func (c *cache) parsePath(p string, t reflect.Type) ([]pathPart, error) { var field *fieldInfo var index64 int64 var err error - parts := make([]pathPart, 0) - path := make([]string, 0) - keys := strings.Split(p, ".") - for i := 0; i < len(keys); i++ { + // pre-allocate parts and path using the number of ".". + // path will be appended to every valid element of the path, but parts will + // only be appended to for each slice of struct. + n := strings.Count(p, ".") + if n == 0 { // note: go1.21 max() + n = 1 + } + parts := make([]pathPart, 0, n) + path := make([]string, 0, n) + var it pathIter + for key, ok := it.start(p); ok; key, ok = it.advance() { if t.Kind() != reflect.Struct { return nil, errInvalidPath } if struc = c.get(t); struc == nil { return nil, errInvalidPath } - if field = struc.get(keys[i]); field == nil { + if field = struc.get(key); field == nil { return nil, errInvalidPath } // Valid field. Append index. path = append(path, field.name) if field.isSliceOfStructs && (!field.unmarshalerInfo.IsValid || (field.unmarshalerInfo.IsValid && field.unmarshalerInfo.IsSliceElement)) { // Parse a special case: slices of structs. - // i+1 must be the slice index. + // next key must be the slice index. // // Now that struct can implements TextUnmarshaler interface, // we don't need to force the struct's fields to appear in the path. // So checking i+2 is not necessary anymore. - i++ - if i+1 > len(keys) { + key, ok = it.advance() + if !ok { return nil, errInvalidPath } - if index64, err = strconv.ParseInt(keys[i], 10, 0); err != nil { + if index64, err = strconv.ParseInt(key, 10, 0); err != nil { return nil, errInvalidPath } parts = append(parts, pathPart{ - path: path, + path: path[:len(path):len(path)], // note: go1.21 slices.Clip() field: field, index: int(index64), }) - path = make([]string, 0) + path = path[len(path):] // Get the next struct type, dropping ptrs. if field.typ.Kind() == reflect.Ptr { @@ -104,10 +148,11 @@ func (c *cache) parsePath(p string, t reflect.Type) ([]pathPart, error) { } // Add the remaining. parts = append(parts, pathPart{ - path: path, + path: path[:len(path):len(path)], // note: go1.21 slices.Clip() field: field, index: -1, }) + parts = parts[:len(parts):len(parts)] // note: go1.21 slices.Clip() return parts, nil } diff --git a/decoder_test.go b/decoder_test.go index d01569e..1b0768a 100644 --- a/decoder_test.go +++ b/decoder_test.go @@ -2527,3 +2527,68 @@ func TestDecoder_SetMaxSize(t *testing.T) { } }) } + +type keyvalue struct { + Key string + Value string +} + +type sliceOfStructInput struct { + Attributes []keyvalue + Tags []keyvalue +} + +func BenchmarkSliceOfStruct(b *testing.B) { + d := NewDecoder() + v := map[string][]string{ + "Attributes.0.Key": {"foo0"}, + "Attributes.0.Value": {"bar0"}, + "Attributes.1.Key": {"foo1"}, + "Attributes.1.Value": {"bar1"}, + "Attributes.2.Key": {"foo2"}, + "Attributes.2.Value": {"bar2"}, + "Attributes.3.Key": {"foo3"}, + "Attributes.3.Value": {"bar3"}, + "Attributes.4.Key": {"foo4"}, + "Attributes.4.Value": {"bar4"}, + "Attributes.5.Key": {"foo5"}, + "Attributes.5.Value": {"bar5"}, + "Tags.0.Key": {"baz0"}, + "Tags.0.Value": {"bam0"}, + "Tags.1.Key": {"baz1"}, + "Tags.1.Value": {"bam1"}, + "Tags.2.Key": {"baz2"}, + "Tags.2.Value": {"bam2"}, + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + in := &sliceOfStructInput{} + _ = d.Decode(in, v) + } +} + +func TestPathIter(t *testing.T) { + testcases := []string{ + "a", + "a.b", + ".b.", + ".", + "..", + "", + } + for _, tc := range testcases { + t.Run(tc, func(t *testing.T) { + var ( + it pathIter + split = strings.Split(tc, ".") + collect = make([]string, 0, len(split)) + ) + for k, ok := it.start(tc); ok; k, ok = it.advance() { + collect = append(collect, k) + } + if !reflect.DeepEqual(split, collect) { + t.Fatalf("expected: %q | got: %q", split, collect) + } + }) + } +}