1

I started a new job and we've been instructed to use Ubers Go coding standards. I'm not sure about one of their guidelines entitled "Exit Once":

If possible, prefer to call os.Exit or log.Fatal at most once in your main(). If there are multiple error scenarios that halt program execution, put that logic under a separate function and return errors from it.

Wouldn't this just mean offloading main() into another function (run())? This seems a little superfluous to me. What benefits does Uber's approach have?

jub0bs
  • 60,866
  • 25
  • 183
  • 186
NimaKapoor
  • 119
  • 6
  • It just means to call os.Exit just one in func main. How you do that (e.g. via run()) doesn't matter and is not important to that rule. – Volker Mar 23 '22 at 10:12
  • 2
    If you've been instructed, you should know what it means to your employer. If you've just been told, ask for instructions. – The Fool Mar 23 '22 at 10:20
  • The intention of the guideline is to produce code that handles fatal errors in one place. In the example you've cited, 3 `log.Fatal` calls get replaced by a single `log.Fatal` – byxor Mar 23 '22 at 13:49
  • @Volker The rationale behind Uber's guideline is more profound than you think. – jub0bs Mar 25 '22 at 07:49
  • @byxor The intention isn't simply to reduce the number of calls to `log.Fatal`. See my answer. – jub0bs Mar 25 '22 at 07:50

1 Answers1

2

I'm not familiar with Uber's entire Go coding standards, but that particular piece of advice is sound. One issue with os.Exit is that it puts an end to the programme very brutally, without honouring any deferred function calls pending:

Exit causes the current program to exit with the given status code. Conventionally, code zero indicates success, non-zero an error. The program terminates immediately; deferred functions are not run.

(my emphasis)

However, those deferred function calls may be responsible for important cleanup tasks. Consider Uber's example code snippet:

package main

func main() {
  args := os.Args[1:]
  if len(args) != 1 {
    log.Fatal("missing file")
  }
  name := args[0]

  f, err := os.Open(name)
  if err != nil {
    log.Fatal(err)
  }
  defer f.Close()

  // If we call log.Fatal after this line,
  // f.Close will not be called.

  b, err := ioutil.ReadAll(f)
  if err != nil {
    log.Fatal(err)
  }

  // ...
}

If ioutil.ReadAll returns a non-nil error, log.Fatal is called; and because log.Fatal calls os.Exit under the hood, the deferred call to f.Close will not be run. In this particular case, it's not that serious, but imagine a situation where deferred calls involved some cleanup, like removing files; you'd leave your disk in an unclean state. For more on that topic, see episode #112 of the Go Time podcast, in which these considerations were discussed.

Therefore, it's a good idea to eschew os.Exit, log.Fatal, etc. "deep" in your programme. A run function as described in Uber's Go coding standards allows deferred calls to be run as they should before programme execution ends (potentially with a non-zero status code):

package main

func main() {
  if err := run(); err != nil {
    log.Fatal(err)
  }
}

func run() error {
  args := os.Args[1:]
  if len(args) != 1 {
    return errors.New("missing file")
  }
  name := args[0]

  f, err := os.Open(name)
  if err != nil {
    return err
  }
  defer f.Close()

  b, err := ioutil.ReadAll(f)
  if err != nil {
    return err
  }

  // ...
}

An additional benefit of this approach is that, although the main function itself isn't readily testable, you can design such a run function with testability in mind; see Mat Ryer's blog post on that topic.

jub0bs
  • 60,866
  • 25
  • 183
  • 186