Go function argument patterns

Naive style: “require-everything”

1 2 3 4 5 6 7 8 9 // Bad: brittle, hard to understand. MyFn("required arg", "foo", 0, nil, "bar", DefaultValue) // Still bad: more readable, but still brittle. If an argument is added // in the definition, or the order of arguments is changed, // the call sites can become wrong without any compile error. MyFn("required arg", "foo" /* argName1 */, 0 /* argName2 */, nil /* argName3 */, "bar" /* argName4 */, DefaultValue /* argName5 */)

 

  • Pros: Simplest implementation.

  • Cons: Verbose, fragile, confusing.

Conclusion: Great for a small number of naturally required args, when all arguments have different types.

Refactor away from this as soon as the argument list starts using multiple values of the same type.

Functional option style

1 2 3 MyFn("required arg", WithOptionalArg("foo"), WithAnotherArg("bar"))
  • Pros: Very nice interface at call-site.

  • Cons:

    • Lots of supporting boilerplate makes code bloated and distracts from the actual implementation.

    • Clutters namespace with symbols useless outside the scope of the argument list.

    • Having identically-named arguments to multiple functions in the same namespace is tricky.

    • Large performance overhead due to mandatory heap allocations.

Conclusion: Since this may be the best caller experience, it's great for API and component boundaries, especially when the API exports only one or two functions so the namespace issues are less apparent. The boilerplate involved makes it overkill for more internal functions.

The performance overhead restricts applicability to functions that are only occasionally called.

Idea: It shouldn't be hard to target this convention using code-generation to adapt from the "structured option style". The boilerplate code could then live in its own file to be less distracting to engineers browsing the implementation.

Structured option style

1 2 3 4 5 MyFn("required arg", MyFnArgs{ OptionalArg: "foo", AnotherArg: "bar", })
  • Pros: Handles optional args nearly as well as "functional option style" with much less boilerplate.
    No performance overhead if the struct is passed by-value.

  • Cons: Doesn't handle non-zero-valued defaulting as well.

Conclusion: This is has a good balance of readability at both caller and implementation sites, making it good for internal functions.

Function object style

Variation 1: monadic style

1 2 3 4 MyFnObj("required arg"). WithOptionalArg("foo"). WithAnotherArg("bar"). Call()
  • Pros:

    • Support for multiple sets of defaults.

    • Lazy evaluation can be added afterwards without changing the call sites.

    • Can be structured to have zero performance overhead.

    • Multiple similar calls can reuse the same object without re-specifying all the arguments.

    • Callables with matching return types can share interface { Call() return_type } regardless of the function’s inputs.

  • Cons: As much boilerplate as "functional option style," though equally amenable to code generation.

Variation 2: mutable closures

1 2 3 4 mfo := MyFnObj("required arg") mfo.OptionalArg = "foo" mfo.AnotherArg = "bar" mfo.Call()
  • Pros:

    • Same pros as above.

    • Less boilerplate needed.

  • Cons: Can't be prepared and called in one expression if there are optional arguments.

Variation 3: declarative call

1 2 3 4 MyFnObj{ OptionalArg: "foo", AnotherArg: "bar", }.Call()
  • Pros: same as variation 2 above.

  • Cons:

    • Lose support for required args.

    • Call() handles non-zero-value defaulting, making explicitly passing zero-values difficult.

Overall conclusion

It goes a little against the principle of "letting things be what they are." If a thing exists solely to be called, shouldn't that thing be a function instead of a struct? At the same time, Go offers better flexibility for defining struct values than it does for argument lists.