An interesting mistake with Go's context package that I (sort of) made

August 29, 2020

Today, Dave Cheney did another Go pop quiz on Twitter, where he asked whether the following code printed -6, 0, '<nil>', or paniced:

package main
import (
    "context"
    "fmt"
)

func f(ctx context.Context) {
    context.WithValue(ctx, "foo", -6)
}

func main() {
    ctx := context.TODO()
    f(ctx)
    fmt.Println(ctx.Value("foo"))
}

I didn't answer this correctly because I focused my attention on the wrong thing.

What I focused on was the use of the "foo" string as the context key, partly because of my experience with languages like Python. To start with, the context package's documentation says:

The provided key must be comparable and should not be of type string or any other built-in type to avoid collisions between packages using context. Users of WithValue should define their own types for keys. [...]

A traditional problem in languages like Python is that two strings may compare the same without actually being the same thing, and some code really wants you to present it with the exact same thing. However, the context package doesn't require that you present it with the exact same key, just a key where the interface value of the key will compare the same.

(Because context compares interface values, both the value and the type must match; it's not enough for both values to have the same underlying concrete type, say string, and to compare identical. This is why defining your own string type is a reliable away around collisions between packages.)

So after I worked through all of this, I confidently answered that this code printed -6. The "foo" string that the value is set with is not necessarily the same "foo" string that it's retrieved with, but that doesn't matter. However, this is not the problem with the code. The actual problem is that context.WithValue() returns a new context with the value set, it doesn't change the context it's called on. Dave Cheney's code is written as if .WithValue() mutates the current context, as f() ignores that new context that .WithValue() provides and returns nothing to main(). Since the original context in main() is what .Value() is called on, it has no "foo" key and the result is actually '<nil>'.

This problem with the code is actually a quite interesting mistake, because as far as I can tell right now none of the usual Go style checkers detect it. This code passes 'go vet', it produces no complaints from errcheck because we're not ignoring an error return value, and tools like golangci-lint only complain about the use of the built-in type string as the key in .WithValue(). Nothing seems to notice that we're ignoring the critical return value from .WithValue(), which turns it into more or less a no-op.

(Now that Dave Cheney has brought this to the surface, I suspect that someone will contribute a check for it to staticcheck, which already detects the 'using a built-in type as a key' issue.)


Comments on this page:

By lilydjwg at 2020-08-30 10:31:31:

In my experience WithXXX always creates a new copy. Python's pathlib.Path and Rust's Path all have this kind of usage.

Anyway some linter should warn about discarding newly-created values. Rust has an attribute to mark this #[must_use], and C compiler warns for certain cases (but I don't know a way to mark in my own code). But not other languages I know about. In Python I'm often caught by not awaiting a coroutine (to actually run the coroutine).

Written on 29 August 2020.
« My divergence from 'proper' Vim by not using and exploring features
All forms of signing email are generally solving the wrong problem (a thesis) »

Page tools: View Source, View Normal.
Search:
Login: Password:

Last modified: Sat Aug 29 23:24:14 2020
This dinky wiki is brought to you by the Insane Hackers Guild, Python sub-branch.