Sooner or later every Go programmer will want to extract an object from another object such as Context or utilize an object that can be set to nil throughout the call stack. However, this can lead to ugly code that looks like:
func someFunc(obj *MyObj) {
if obj != nil {
DoThis()
}
otherFunc(obj)
}
func otherFunc(obj *MyObj) {
if obj != nil {
DoThis()
}
...
}
I this case, if our obj != nil, we want to perform an action.
A use case I recently ran into was the extraction of a *Tracer object from a Context in an RPC call. A Tracer object not being part of the Context is a valid state we want to support, as not all executions should receive a Tracer.
The basic structure of the tracer library looked similar to this:
// Extract a Tracer from a Context object that is at key.
// Note: I'm using a string as the key type for brevity, best
// practices say use a custom type.
func FromContext(ctx context.Context, key string) *Tracer {
v := ctx.Value(key)
if v == nil {
return nil
}
return v.(*Tracer)
}
// NewContext adds a Tracer to the Context at key.
func NewContext(ctx context.Context, key string, t *Tracer) context.Context {
...
}
// Tracer provides execution tracing for RPCs.
type Tracer struct{
...
}
// NewTracer creates a new Tracer object that logs to fPath.
func NewTracer(fPath string) (*Tracer, error){
...
}
// FuncTimer creates a timer and immediately logs the function entrance
// time. This should be the first line in a function call.
func (*Tracer) FuncTimer() *Timer {
...
}
// Close closes the Tracer and any open files.
func (*Tracer) Close() {
...
}
// Timer provides methods for logging timing information for function calls.
type Timer struct{
...
}
// Close stops the Timer and logs the function exit and duraction.
// This is normally used in a defer as the second line in a function call.
func (t *Timer) Close() {
...
}
The most obvious way of using this library would be:
func myFunc(ctx context.Context) {
t := FromContext(ctx, "someKey").FuncTimer()
if t != nil {
defer t.Close()
}
...
}
While this isn't too burdensome, I don't like the idea of having to test against nil in 40 different function calls or having the possibility that a call to the Timer could ever cause a panic.
In addition, if I was to extend Timer to have a method like:
func (t *Timer) Logf(s string, a ...interface{}) {
...
}
I would then have a lot more burden:
func myFunc(ctx context.Context) {
t := FromContext(ctx, "someKey").FuncTimer()
if t != nil {
defer t.Close()
}
...
if t != nil {
t.Logf("this is interesting")
}
...
if t != nil {
t.Logf("this too")
}
}
We can take the burden away from the user simply by providing no-op Timer
methods.
Instead of the previous Logf() and Close() options as written above, we can add the following to them to allow no-op calls:
func (t *Timer) Logf(s string, a ...interface{}) {
if t == nil { // Test to see if we are a *Timer that is nil.
return
}
...
}
func (t *Timer) Close() {
if t == nil {
return
}
...
}
This allows us to shrink our code to:
func myFunc(ctx context.Context) {
t := FromContext(ctx, "someKey").FuncTimer()
defer t.Close()
...
t.Logf("this is interesting")
...
t.Logf("this too")
}
Regardless of the presence of "someKey" on the Context object, a *Timer will always be returned. In the case of a nil *Timer, no code will panic and the code flow will be easier to follow.
When using interfaces instead of concrete types, no-op objects can be implemented as well. Simply implement the interface with a type that has no-op methods and provide it when a value isn't available.
No-Ops can be powerful tools in your developer toolbelt. Don't forget to use them.