📚 decouple - Awesome Go Library for Go Tools

Go Gopher mascot for decouple

Find “overspecified” function parameters that could be generalized with interface types.

🏷️ Go Tools
📂 Plugin for text editors and IDEs.
21 stars
View on GitHub 🔗

Detailed Description of decouple

Decouple - find overspecified function parameters in Go code

Go Reference Go Report Card Tests Coverage Status Mentioned in Awesome Go

This is decouple, a Go package and command that analyzes your Go code to find “overspecified” function parameters.

A parameter is overspecified, and eligible for “decoupling,” if it has a more-specific type than it actually needs.

For example, if your function takes a *os.File parameter, but it’s only ever used for its Read method, it could be specified as an abstract io.Reader instead.

Why decouple?

When you decouple a function parameter from its too-specific type, you broaden the set of values on which it can operate.

You also make it easier to test. For a simple example, suppose you’re testing this function:

func CountLines(f *os.File) (int, error) {
  var result int
  sc := bufio.NewScanner(f)
  for sc.Scan() {
    result++
  }
  return result, sc.Err()
}

Your unit test will need to open a testdata file and pass it to this function to get a result. But as decouple can tell you, f is only ever used as an io.Reader (the type of the argument to bufio.NewScanner).

If you were testing func CountLines(r io.Reader) (int, error) instead, the unit test can simply pass it something like strings.NewReader("a\nb\nc").

Installation

go install github.com/bobg/decouple/cmd/decouple@latest

Usage

decouple [-v] [-json] [DIR]

This produces a report about the Go packages rooted at DIR (the current directory by default). With -v, very verbose debugging output is printed along the way. With -json, the output is in JSON format.

The report will be empty if decouple has no findings. Otherwise, it will look something like this (without -json):

$ decouple
/home/bobg/kodigcs/handle.go:105:18: handleDir
    req: [Context]
    w: io.Writer
/home/bobg/kodigcs/handle.go:167:18: handleNFO
    req: [Context]
    w: [Header Write]
/home/bobg/kodigcs/handle.go:428:6: isStale
    t: [Before]
/home/bobg/kodigcs/imdb.go:59:6: parseIMDbPage
    cl: [Do]

This is the output when running decouple on the current commit of kodigcs. It’s saying that:

  • In the function handleDir, the req parameter is being used only for its Context method and so could be declared as interface{ Context() context.Context }, allowing objects other than *http.Request values to be passed in here (or, better still, the function could be rewritten to take a context.Context parameter instead);
  • Also in handleDir, w could be an io.Writer, allowing more types to be used than just http.ResponseWriter;
  • Similarly in handleNFO, req is used only for its Context method, and w for its Write and Header methods (more than io.Writer, but less than http.ResponseWriter);
  • Anything with a Before(time.Time) bool method could be used in isStale, it does not need to be limited to time.Time;
  • The *http.Client argument of parseIMDbPage is being used only for its Do method.

Note that, in the report, the presence of square brackets means “this is a set of methods,” while the absence of them means “this is an existing type that already has the right method set” (as in the io.Writer line in the example above). Decouple can’t always find a suitable existing type even when one exists, and if two or more types match, it doesn’t always choose the best one.

The same report with -json specified looks like this:

{
  "PackageName": "main",
  "FileName": "/home/bobg/kodigcs/handle.go",
  "Line": 105,
  "Column": 18,
  "FuncName": "handleDir",
  "Params": [
    {
      "Name": "req",
      "Methods": [
        "Context"
      ]
    },
    {
      "Name": "w",
      "Methods": [
        "Write"
      ],
      "InterfaceName": "io.Writer"
    }
  ]
}
{
  "PackageName": "main",
  "FileName": "/home/bobg/kodigcs/handle.go",
  "Line": 167,
  "Column": 18,
  "FuncName": "handleNFO",
  "Params": [
    {
      "Name": "req",
      "Methods": [
        "Context"
      ]
    },
    {
      "Name": "w",
      "Methods": [
        "Header",
        "Write"
      ]
    }
  ]
}
{
  "PackageName": "main",
  "FileName": "/home/bobg/kodigcs/handle.go",
  "Line": 428,
  "Column": 6,
  "FuncName": "isStale",
  "Params": [
    {
      "Name": "t",
      "Methods": [
        "Before"
      ]
    }
  ]
}
{
  "PackageName": "main",
  "FileName": "/home/bobg/kodigcs/imdb.go",
  "Line": 59,
  "Column": 6,
  "FuncName": "parseIMDbPage",
  "Params": [
    {
      "Name": "cl",
      "Methods": [
        "Do"
      ]
    }
  ]
}

Performance note

Replacing overspecified function parameters with more-abstract ones, which this tool helps you to do, is often but not always the right thing, and it should not be done blindly.

Using Go interfaces can impose an abstraction penalty compared to using concrete types. Function arguments that could have been on the stack may end up in the heap, and method calls may involve a virtual-dispatch step.

In many cases this penalty is small and can be ignored, especially since the Go compiler may optimize some or all of it away. But in tight inner loops and other performance-critical code it is often preferable to operate only on concrete types when possible.

That said, avoid the fallacy of premature optimization. Write your code for clarity and utility first. Then sacrifice those for the sake of performance not in the places where you think they’ll make a difference, but in the places where you’ve measured that they’re needed.