skip to content

cmp.Ordered or a comparator function: which shape do you commit to for an ordering API other teams import?

level: principalimportance: nice to knowfreq 20%

answer

  1. an exported signature is a promise
  2. adding is free, changing breaks callers
  3. one type set is closed, one open
  4. you cannot add a method to int
  5. ship the primitive, wrap the convenience

basics

~10 s

Make the comparator-taking function the primitive and add a cmp.Ordered-constrained wrapper on top. Adding an exported function later is compatible; changing one's parameters is not, so commit to the shape that excludes nobody.

solid answer

~50 s

This is an exported signature other teams pin, so ask which shape you can live with in two years. Constraining to `cmp.Ordered` gives the smallest call site, no boxing and no dispatch, and it covers `int`, `string` and `float64` — types nobody can add a method to. But its type set is **closed**: a caller ordering `time.Time`, a struct by two keys, or strings case-insensitively cannot use it at all, and their only escape is to fork you. A `func(a, b T) int` parameter accepts all of those, at the cost of a noisier call site and a contract you must document — negative, zero, positive, consistent for every pair. Ship both: the comparator form as the primitive, the `cmp.Ordered` form as a thin wrapper. Refuse an interface with a `Compare` method — it forces a wrapper type around `int` at every call site.

code

go · 9 lines
go
func MaxOrdered[T cmp.Ordered](vals ...T) T {
	best := vals[0]
	for _, v := range vals[1:] {
		if cmp.Compare(v, best) > 0 {
			best = v
		}
	}
	return best
}

go deeper

for a junior

Notice that a function constrained to cmp.Ordered only accepts numbers and strings, while one taking a comparison function accepts any type at all. That difference is the whole trade.

for a middle

Be able to explain the mechanics behind the trade: the constraint compiles to the built-in comparison with no interface value, while a comparator parameter costs an indirect call and buys an open set of element types.

for a senior

Show you would keep both shapes reachable and would document the comparator contract, because an inconsistent caller-supplied comparison produces run-to-run differences that get reported as your package's bug.

for a principal

Own the compatibility argument: adding an exported function is free and changing one is not, so commit to the shape that cannot lock a future caller out, and settle the floating-point policy once for every team that imports you.

## What is actually being decided A package other teams import has one property that an internal helper does not: **its exported signatures are pinned**. Once modules depend on your package, changing a function's parameter list is a breaking change that forces a major version and a coordinated upgrade across every consumer. Adding a *new* exported function is free. That asymmetry, not elegance, should drive this call. The two candidate shapes: ```go // A: constraint-shaped func MaxOrdered[T cmp.Ordered](vals ...T) T // B: comparator-shaped func MaxBy[T any](vals []T, compare func(a, b T) int) T ``` ## What shape A buys and what it costs **Buys.** The call site is minimal — `MaxOrdered(scores...)` — with no second argument to get wrong. The comparison compiles to the machine `<` for the instantiated type: no interface value is allocated, no dynamic dispatch happens per element. It works out of the box on the predeclared types, which matters because you cannot declare a method on `int` outside the package that defines it, so no method-based scheme can ever cover them without wrappers. And because the `~` approximation is in the type set, a team's own `type Score float64` works with no ceremony. **Costs.** The type set is closed and you do not own it. A caller with `time.Time` — whose order lives in a `Compare` method — is locked out. So is a caller ordering a struct by two fields, or ordering strings case-insensitively, or wanting a descending order without negating values. Those callers cannot extend your API; they can only wrap it awkwardly or copy it. Every one of them becomes a support conversation, and eventually a request to change the signature you cannot change. ## What shape B buys and what it costs **Buys.** The type set is open: any `T` at all, with the ordering supplied by the caller. Multi-key orders, reversed orders, locale rules, method-based orders — all expressible without touching your package. It is also the shape you can build A out of, not the reverse. **Costs.** Every call site carries a function literal, which is noise when the element is a plain `int`. You now own a *contract* — the returned int must be negative, zero or positive, and the function must be consistent for every pair regardless of the surrounding elements — and you must document it, because a caller who writes an inconsistent comparator will produce results that vary between runs and will file the bug against you. There is an indirect call per comparison, which is measurable only on very hot paths and is usually the wrong thing to optimise first. ## The settlement, and why Ship both, with B as the primitive and A implemented in terms of it. This is the convention the standard library follows: a plain function for the common ordered case, and a `...Func` sibling taking a comparator for everything else. It costs one extra exported name, and it means the caller you did not anticipate never has to fork you. If you can only ship one now, ship the comparator form, because the constraint form can be *added* later without breaking anyone, whereas discovering you needed the comparator after teams have pinned the constrained signature leaves you with a major version. ## The shape to refuse An interface-based `Comparable` with a `Compare(other T) int` method looks object-oriented and idiomatic to engineers arriving from other languages, and it is the wrong choice here. Methods can only be declared in the package that defines the type, so `int`, `string` and `float64` — the overwhelming majority of your calls — need a named wrapper type at every call site. On top of that, every element is boxed into an interface value on the way in. The constraint exists precisely because the language cannot attach behaviour to predeclared types, and this is the case it was designed for. ## The decisions to make once, in the package - **The floating-point policy.** Decide that your package's ordering is `cmp.Compare`'s: NaN below every number, NaN equal to NaN, signed zeros equal. Document it. If every team re-derives its own rule, you will get three different answers and two of them will be inconsistent. - **What "unset" means.** If the domain has absent values, decide whether they are represented by the zero value or by a separate signal, and say so, because `cmp.Or`-style precedence chains cannot distinguish an absent value from a deliberate zero. - **The comparator contract.** Write down that the function must be consistent and total, and say what your package does not promise if it is not — including that results may differ between runs. ## Who owns the consequences The package owner makes this call and the API reviewer can overrule it, and the argument that should win is not performance but reachability: which shape leaves a future caller with a supported path. Constraining to `cmp.Ordered` is the cheaper call site and the narrower promise; a comparator parameter is the wider promise and the one you can keep. When a downstream team hits the wall, the cost of the fork is theirs but the cost of the divergence is the whole organisation's, which is the reason to spend one extra exported name up front.

  • Why not require callers to implement a Compare method on their element type?
    Because methods can only be declared in the package that defines the type, so `int`, `string` and `float64` would each need a wrapper type at every call site — and those are most of your calls. It also boxes every element into an interface value. The constraint exists for exactly this gap.
  • If you can ship only one function now, which one, and why?
    The comparator-taking one. A constraint-shaped convenience wrapper can be added later with no break, because adding an exported function is backwards compatible. Discovering afterwards that you needed the comparator, once teams have pinned the constrained signature, costs a major version and a coordinated upgrade.
  • How do you keep a comparator parameter from becoming a support burden?
    Document the contract explicitly — negative, zero or positive, and consistent for every pair — and say what the package does not promise when it is violated, including that results may differ between runs. Then supply the ordered-type wrapper so the common case never has to write one at all.
  • Where should the NaN decision live in a package like this?
    In the package, once, and in its documentation. Adopt `cmp.Compare`'s rules — NaN below every number, NaN equal to NaN, signed zeros equal — so every importing team inherits one answer. Leaving it to callers guarantees several different rules, some of them inconsistent.

saying these in an interview costs you the question

  • Picks the shape purely on avoiding an indirect call
  • Assumes an exported signature can be changed later
  • Proposes an interface with a Compare method for int
  • Ignores callers whose order lives in a method
  • Leaves the NaN rule to each importing team