Search code examples
gosliceprepend

Go | efficient and readable way to append a slice and send to variadic function


Let's say I have the following pipeline of functions:

func func3(opts ...FunctionObject) {
    for _, opt := range opts {
        opt()
    }
}

func func2(opts ...FunctionObject) {
   var functions []FunctionObject
   functions = append(functions, someFunction3)
   functions = append(functions, someFunction4)
...
...
...
    func3(append(functions, opts...)...)
}


func func1(opts ...FunctionObject) {
   var functions []FunctionObject
   functions = append(functions, someFunction)
   functions = append(functions, someFunction2)
...
...
...
    func2(append(functions, opts...)...)
}

For reasons inherited in the problem I want to solve, the functions in functions should be called before the functions in opts , so i can't just append to opts but I have to prepend functions to opts (by append(functions, opts...) ) and then using ... again to send it to next function in the pipeline, so im getting the weird expression:

func2(append(functions, opts...)...)

I don't know how efficient it is, but Im sure it looks weird,
There must be a better way of doing it, and that's what Im looking for.

yet i'd be grateful for accompanying explanation about efficiency :)

Edit: I can't change the the argument type from opts ...FunctionObject to opts []FunctionObject (as @dev.bmax suggested in comments) since im making changes in an existing codebase so i can't change the functions that call func{1,2,3}

  1. by saying that "it looks weird" i don't mean only of the "look" but it looks weird to do this operation (ellipsis) twice, and it seems to be inefficient (am i wrong?)

Solution

  • Prepending to a slice is fundamentally inefficient since it will require some combination of:

    • allocating a larger backing array
    • moving items to the end of the slice
    • ...or both.

    It would be more efficient if you could change the calling convention between functions to only append options and then process them in reverse. This could avoid repeatedly moving items to the end of the slice and potentially all allocations beyond the first (if enough space is allocated in advance).

    func func3(opts ...FunctionObject) {
        for i := len(opts) - 1; i >= 0; i-- {
            opts[i]()
        }
    }
    

    Note: func3(opts ...FunctionObject) / func3(opts...) and func3(opts []FunctionObject) / func3(opts) are equivalent for performance. The former is effectively syntactic sugar for passing the slice.

    However, you've mentioned you need to keep your calling conventions...

    Your example code will cause allocations for the 1st, 2nd, 3rd, 5th,.. append within each function - allocations are needed to double the size of the backing array (for small slices). append(functions, opts...) will likely also allocate if the earlier appends didn't create enough spare capacity.

    A helper function could make the code more readable. It could also reuse spare capacity in the opts backing array:

    func func2(opts ...FunctionObject) {
        // 1-2 allocations. Always allocate the variadic slice containings 
        // prepend items. Prepend reallocates the backing array for `opts`
        // if needed.
        opts = Prepend(opts, someFunction3, someFunction4)
        func3(opts...)
    }
    
    // Generics requires Go1.18+. Otherwise change T to FunctionObject.
    func Prepend[T any](base []T, items ...T) []T {
        if size := len(items) + len(base); size <= cap(base) {
            // Extend base using spare slice capacity.
            out := base[:size]
            // Move elements from the start to the end of the slice (handles overlaps).
            copy(out[len(items):], base)
            // Copy prepended elements.
            copy(out, items)
            return out
        }
        return append(items, base...) // Always re-allocate.
    }
    

    Some alternate options without the helper function that describe the allocations in more detail:

    // Directly allocate the items to prepend (2 allocations).
    func func1(opts ...FunctionObject) {
        // Allocate slice to prepend with no spare capacity, then append re-allocates the backing array
        // since it is not large enough for the additional `opts`.
        // In future, Go could allocate enough space initially to avoid the
        // reallocation, but it doesn't do it yet (as of Go1.20rc1).
        functions := append([]FunctionObject{
            someFunction,
            someFunction2,
            ...
        }, opts...)
        // Does not allocate -- the slice is simply passed to the next function.
        func2(functions...)
    }
    
    // Minimise allocations (1 allocation).
    func func2(opts ...FunctionObject) {
       // Pre-allocate the required space to avoid any further append
       // allocations within this function.
       functions := make([]FunctionObject, 0, 2 + len(opts))
       functions = append(functions, someFunction3)
       functions = append(functions, someFunction4)
       functions = append(functions, opts...)
       func3(functions...)
    }
    

    You could go further and reuse with spare capacity in opts without needing to allocate a slice containing the items to prepend (0-1 allocations per function). However this is complex and error prone -- I wouldn't recommend it.