Go 2.0 proposal: context scoped variables

255 views
Skip to first unread message

Sam Vilain

unread,
Aug 11, 2017, 5:19:25 PM8/11/17
to golang-nuts
Many have written about this; there's an index on Go wiki under ExperienceReports#context including my own writing for the 2016 advent calendar.

I would like to propose a simpler way to solve the "optional variable access" part of context as a language feature.  The core problem I'm trying to solve is the necessity for large scale refactoring to add the ctx variable to every caller in a stack.  I've done this before for the purpose of adding log tagging, tracing, cancellation, and database transactions/savepoints management.  I believe it is a good fit for those cases.  I've also seen bad usages of it - people shoehorning required variables into it to escape the difficulties of rethinking their design.

The basics:
  • the runtime adds an extra stack argument for its equivalent of the Context structure.
    • this argument is not directly accessible
    • it may be eliminated/optimized out by the compiler where not used
    • it may be "unrolled" to more than one stack argument for performance
  • a special keyword added for declaring/accessing 'context variables'
  • assigning to a context variable creates a new context
  • may make cancelation easy and natural
Setting context variables

So, for example, this use of context.Context:

type myPrivateType struct{}
var myPrivateKey myPrivateType

func someFunc(ctx context.Context, args... interface{}) {
    localInfo := &LocalInfo{}
    ctx := context.WithValue(ctx, myPrivateKey, localInfo)
    ...
}

Would instead be:

func someFunc(args... interface{}) {
    context var localInfo := &LocalInfo{}
    ...
}
 
Retrieving context variables

*Retrieving* a value would change from:
 
func otherFunc(ctx context.Context, args... interface{}) {
    localInfo, ok := ctx.Value(myPrivateKey).(*LocalInfo)
    ...
}
To:

 func otherFunc(args... interface{}) {
     context var localInfo *LocalInfo
     ...
}

Mid-function variable re-assignment

Re-assigning a previously declared variable would have the same effect as re-assigning a ctx variable in scope:

func someFunc(ctx context.Context, args... interface{}) {
    localInfo, ok := ctx.Value(myPrivateKey).(*LocalInfo)
    ... 
    if somecondition {
        ctx = context.WithValue(ctx, myPrivateKey, localInfo)
    }
    ...
}

Would be equivalent to:

func someFunc(ctx context.Context, args... interface{}) {
    context var localInfo *LocalInfo
    ... 
    if somecondition {
        localInfo = &LocalInfo{}
    }
    ...
}

I wouldn't treat this exact equivalence as a hard rule; this re-assigning would only be expected to affect that particular context variable. 

Public context variables

It could also be possible to access other packages' public context variables:
func someFunc(args... interface{}) {
    context var pkg.FooVariable otherPkg.SomeType 
    ...
}
This declaration is a little weird, because it includes a package name in the variable name.  The immediate question is, is this variable accessed later as "pkg.FooVariable" or just "FooVariable"?  I would lean towards the former to be less surprising and to avoid potential namespace clashes.

This could be useful for log tagging; APIs would look like;

    context var logging.Tags []zap.Field
    logging.Tags = append(logging.Tags, zap.String("rqID", rqUUID))

Where logging is some project-global logging module.

This isn't quite the pattern I used in my context logging post, which was:

logging module:
    // WithRqId returns a context which knows its request ID
    func WithRqId(ctx context.Context, rqId string) context.Context {
        return context.WithValue(ctx, requestIdKey, requestId)
    }

calling package:
    func RequestHandler(w http.ResponseWriter, r *http.Request) {
        rqId := uuid.NewRandom()
        rqCtx := logging.WithRqId(httpContext, rqId)
        ...

This one-line style of tagging a context in that last block could be supported with this sort of call:

    context var logging.Tags := logging.WithRqID(rqID)

In this instance, as logging.WithRqID is evaluated before the logging.Tags context variable is assigned, it accesses the prior value.  The function returns the new variable instead of an entire context.Context struct. 

Context variable scope

The scope of a context variable would be essentially the same as in context: it passes down, but not up. Declaring a context variable without immediately assigning it will behave as in context: if it's there, you get the prior value.  If it's not, you get a zero value (well, context gives you a nil, but the idea is the same).

This style makes it easier to identify use of uninitialized context variables:

func badFunc(args... interface{}) {
    context var logging.Logger zap.Logger 
    logging.Logger.Info("in badFunc()", zap.Object("args", args))
    ...
}

It's quite easy to see here that the logging.Logger variable might be being used uninitialized.  It also looks awkward.

To compare this to the logging pattern I described in my advent calendar post;

func betterFunc(ctx context.Context, args... interface{}) {
    logging.WithContext(ctx).Info("in betterFunc()", zap.Object("args",args))
    ...
}
This pattern should hide this, enabling APIs like this:

func goodFunc(args... interface{}) {
    logging.Info("in betterFunc()", zap.Object("args",args))
    ...
}

As this is less awkward than the 'uninitialized context variable' use case, I would hope that this style would become more natural and popular than the anti-pattern of required context variables.

Context Cancellation

This system could be used to re-implement context cancellation; using the example from the context documentation:

func Stream(ctx context.Context, out chan<- Value) error {
    for {
        v, err := DoSomething(ctx)
        if err != nil {
            return err
        }
        select {
            case <-ctx.Done():
                return ctx.Err()
            case out <- v:
        }
    }
}

This could be written as (assuming the convention is that cancellation happens via a context variable in the sync package):

func Stream(out chan<- Value) error {
    context var sync.Done
    for {
        v, err := DoSomething()
        if err != nil {
            return err
        }
        select {
            case <-sync.Done:
                return ctx.Err()
            case out <- v:
        }
    }
}

Prior Art & Approaches in other languages

As far as I know, only Perl 6 has this concept as an explicit language feature, also called "context variables" at some point (IIRC), which it now calls "the * twigil".

Comments/thoughts welcome!
Sam 
Reply all
Reply to author
Forward
0 new messages