Last week:
This week's takeaways
Return -1 or nil to indicate error.
Understanding "Return fast, return early" to avoid nested code.
Define interfaces in the consumer package, not the producer.
Avoid named results unless necessary for documentation.
Avoid return -1 or nil to indicate Error.
In other languages, it's common practice for functions to indicate errors or missing results by returning special values like -1, null, "",...
This is called "In-band errors".
The main issue with in-band error signaling is, it requires the caller to remember to check for these special values every time.
This is... error-prone.
Also, this approach is not the best (or even good) in Go since it supports multiple return values.
Go's solution: Multiple return values
A function can return its usual result along with an additional value (error or bool) that explicitly signals whether the operation was successful. This makes the code clearer.
Using the result without checking the success indicator (ok bool) results in a compile-time error. And this forces us to handle the possibility of failure explicitly:
So now, your code has 3 things (you don't even need to care):
Clear separation of concerns: Which part of the return value is the actual result and which part indicates the success or failure of the operation.
Forced error handling: The Go compiler requires developers to address the possibility of an error, reducing the risk of overlooking it (so, don't ignore errors with _).
Improved readability and maintainability: The code explicitly documents itself.
There are always exceptions to this approach.
Even though Go prefers using multiple return values to handle errors more clearly, there are times when returning values like nil or -1 is acceptable and practical.
For example, some functions in the Go standard library, especially in the “strings” package, use these special values to indicate certain outcomes. This can make working with strings easier but requires the devs to pay more attention.
Understanding "Return fast, return early" to avoid nested code.
When you're writing code, you want it to be as clear and understandable as possible.
One way to achieve this is by organizing your code so that the "happy path" (the expected or normal flow of execution) is prominent and easy to follow. A (potentially) poor example:
So, what's the guiding principle?
It's simple: deal with errors upfront and then get them out of the way.
This means when an error occurs:
You handle it immediately.
Stop the execution of the current operation using: return, break, continue,...
Or manage the error in a way that allows the normal execution to proceed safely, if appropriate.
Revisiting the earlier example, a better approach is suggested:
"But what if my function returns two values, like fetch user returning (user, error), and the value is used in short-term?"
Even if 'user' is only needed in the else scope, I'd recommend separating the initialization from the error checking. This method avoids deep nesting and simplifies error handling.
"But what if I only want to use 'user' within that else scope?"
If the use of 'user' is strictly limited to within the 'else' and doesn't affect the logic outside of it, then it might be time to encapsulate that logic in a new function.
We now apply the principle to the 'DoSomethingWithUser' function. Of course, there's no one-size-fits-all solution.
Define interfaces in the consumer package, not the producer.
Now, there're 3 principles to remember:
1. Define interfaces in the consumer package, not the producer.
Interfaces should be defined by the consumer (the code that uses the interfaces) rather than the producer (the code that implements these interfaces). This approach makes it easier to add new methods to implementations without affecting consumers.
2. Use concrete types for return values in the producer package.
This is straightforward, as we don't define interfaces in the producer. It allows us to add new methods to these types without breaking the API.
3. Avoid premature interface definitions
Define interfaces only when there is a clear use case, ensuring they are necessary and correctly designed.
Ok, enough with theory and hypothesis. Have you ever done something like this?
Defining the Logger interface in the same package as the consoleLogger. And then, whenever you want to use it, you create the interface (?) in the consumer?
I did this many years ago, not with Logger but with a Repository interface :)
"Is this bad?"
Maybe.
But consider the approach that follows our principles, let's revise it. First, here is our new producer
logger package:
We no longer maintain the Logger interface in the producer anymore.
We return a concrete type when creating a Logger in the producer, right?
We avoid prematurely defining interfaces, with no need to guess what functions the client needs.
Now, let's see how our client (consumer) uses it:
By defining interfaces where they are used (in high-level modules), you ensure that these modules depend on abstractions rather than concrete implementations.
Ah, this also enhances modularity, mock testing & design mindset.
Avoid named results unless necessary for documentation.
Note: Personally, I always avoid using named results because they encourage the use of naked returns.
Named results can enhance the readability of your code in both the source and in generated documentation, like godoc, pktsite (http://pkg.go.dev)
But it's crucial to know when to use them, so let me outline some key points:
1. Clarification when necessary
Do:
Use named results if a function returns multiple values of the same type.
Name them for clarity if their purpose isn't immediately obvious.
Don't:
Use named results just to avoid declaring a variable inside the function.
To avoid repeatedly "return nil, err" in favor of a simple "return".
2. Avoid naked returns in long functions
People are often aggressive about naked returns because they can make the code less readable and affect clarity.
But they're totally okay in short functions:
You can immediately understand their intent at first sight, in longer functions, avoid them.
Be explicit to keep the code readable, you have the choice to use naked returns or not when combining with named results.
3. Necessary for deferred closures
Naming result parameters is important if you need to modify a return value in a deferred function call.
Here, naming the result parameters 'result' and 'err' serves a specific purpose.
It makes these variables accessible within the deferred closure, allowing their modification based on the function's outcome or in response to a panic.
4. Some cases don’t need naming, even with >2 results
When functions return objects of the same type, especially in methods of a type, naming each returned object can be redundant and clutter our documentation. Or the type itself may already be self-explanatory.