19 July 2023
Learning Go
A coworker and I recently had the opportunity to work on a project in a language entirely new to us. Learning Go was an interesting experience, especially coming from primarily Ruby and Elixir. Go has quite good learning resources out there (Go By Example and the Go tour), which is extremely helpful; however, I ultimately found it harder to work with than Ruby and Elixir.
Downsides of Go, and How We Handled Them
First of all, there isn’t an interactive shell. Python has the python shell, Ruby has irb, Elixir has iex–even JavaScript has the browser console in a pinch. Go does have the Go Playground, which–don’t get me wrong–is a great resource. However, it’s not as useful as the Rails console or iex. For one thing, I can’t hook it into my local server to use local code or affect my local database. For another, no matter how lazy this sounds, it’s just not as accessible as my local terminal or a browser console, since it’s a specific webpage I have to navigate to.
We remedied this by creating a second exectuable: a nested main
package with a main
function in a cmd
directory that we could run with go run ./cmd
. This package could easily gain access to our local code, and we could connect it to our local database as well. This was particularly useful for testing integrations with outside APIs that we would mock in our tests, or if we needed to directly manipulate the our data to get it in a particular state for click-testing a side case. It’s not quite as convenient as the Rails console or iex, since it requires running the entire function in one go instead of one line at a time, but it accomplishes the same thing.
Secondly, Go is a strangely repetitive language, especially in error handling. Imagine that I have three string values representing integers. I want to convert these strings into integers, add the first two together, and subtract the third, printing whether or not this succeeded and then returning either the successful result or nil
. Here’s one way to accomplish this in each of Ruby, Elixir, and Go:
Ruby:
# intercepts TypeError and ArgumentError, then returns nil
def ruby_example(input_1, input_2, input_3)
begin
res = Integer(input_1.strip) + Integer(input_2.strip) - Integer(input_3.strip)
print("SUCCESS")
res
rescue TypeError, ArgumentError
print("FAILURE")
end
end
Elixir:
# checks for success, otherwise returns nil
# specifically checks for no extraneous characters to match Go and Ruby implementations
def elixir_example(input_1, input_2, input_3, bool_input) do
with {res_1, ""} <- input_1 |> String.trim() |> Integer.parse(10),
{res_2, ""} <- input_2 |> String.trim() |> Integer.parse(10),
{res_3, ""} <- input_3 |> String.trim() |> Integer.parse(10) do
(res_1 + res_2 - res_3)
|> IO.inspect(label: "SUCCESS")
else
_ -> IO.inspect(nil, label: "FAILURE")
end
end
Go:
// all imports from the Go standard library
import (
"fmt"
"strconv"
"strings"
)
// checks for error value, then returns nil
func goExample(input1, input2, input3 string) (*int, error) {
res1, err := strconv.Atoi(strings.Trim(input1, " "))
if err != nil {
fmt.Println("FAILURE")
return nil, err
}
res2, err := strconv.Atoi(strings.Trim(input2, " "))
if err != nil {
fmt.Println("FAILURE")
return nil, err
}
res3, err := strconv.Atoi(strings.Trim(input3, " "))
if err != nil {
fmt.Println("FAILURE")
return nil, err
}
fmt.Println("SUCCESS")
res := res1 + res2 - res3
return &res, nil
}
In the Ruby example, we intercept a raised error (a common pattern in Ruby). In the first and second Elixir example, we check for successful results and intercept a returned :error
atom, while in the third, we catch a raised error (this is generally not a recommended pattern in Elixir). In Go, we intercept returned errors. This is similar to our above Elixir example, but without an equivalent to Elixir’s pipelines, with
statements, or pattern matching, Go quickly becomes very repetitive. Very occasionally, a Go function will “panic”, which is a delightful term equivalent to throwing (or raising) an error, but this doesn’t seem particularly common. Usually, the function will return a value and an error, and it’s up to you as the developer to check to see if the error exists and handle it. Any given Go function will end up riddled with if err != nil
statements. There is also a recover
function in Go that intercepts a panicking function, but we did not run into throughout our project as it is not commonly used.
Go is ultimately a rather sparse language–which isn’t necessarily a bad thing, but I think it takes the sparseness a bit too far. It has no built-in map or reduce functions, for example, and instead arrays and slices are manipulated via for
loops, which can be tedious. There is, however, an expansive ecosystem of useful packages to help pick up the slack. The ent framework, for example, makes handling database schema and querying relatively simple, despite the large amount of code it generates. Gin is a framework that simplifies routing and handling http requests.
We also had a fair amount of luck using various forms of automated writing assistance, particularly Github Copilot and VSCode’s Go extension. One of Go’s more complex components is pointers, and it can be hard to remember when you need to return a pointer to a value rather than the value itself. Luckily, IDEs and build errors will typically point you to a resulting type error. For example, you may have noticed that in the Go error example above, I returned &res
, which is a *int
, or a pointer to an integer, instead of just res
, which is an int
; this is because the “zero value” of an integer in Go is 0
, not nil
, but the zero value of a pointer is always nil
. I actually initially tried to make the return type of goExample
(int, error)
while still returning nil, err
inside of the error checks, but when I test-ran the function in the Go playground, the build failed at that return statement. Similarly, if I had written the function in a .go
file in VSCode, I would have received a type error.
Testing, too, is pretty limited and complex using only built-in methods, but we were able to simplify it greatly with a few outside packages. We had great success using txdb for creating transactional database interactions in tests and various testify packages to handle assertions.
Advantages of Go
While I did miss pipelines and pattern matching from Elixir, and better error handling and built-in iterators from both Elixir and Ruby, there were a number of things I quite liked about working with Go.
For one, our project consisted of rewriting an existing backend in Go, and we were able to set up a reverse proxy to the existing codebase in a matter of minutes and a few lines of code. This made it easy to keep the rest of the app functional while we worked on converting endpoints over to the Go app bit by bit.
Go is strongly-typed, which, while it can be annoying at times, ultimately greatly increases readability. The only things I don’t like about Go’s type system are how confusing pointers can be (which again, IDE and build errors can help with greatly), and how rigid it is. Specifically, there doesn’t seem to be a way of using union types, which ultimately sets up the need for more repetition in your code–if in Typescript, for example, you might use a union type to call the same function on variables of two different types, in Go you will need two different functions, one for each type. On the other hand, Go’s typing system makes marshalling and unmarshalling JSON supremely easy, even if you need to change a key between systems. Say you an http call that returns JSON with a key of "system_has_date_time"
, but internally in your Go program, you’ve been referring to the same datapoint as TimeExists
. With Go’s typing system, you can easily create a new type:
type DataStructure struct {
TimeExists string `json:"system_has_date_time"`
}
that handles that conversion when you run json.Unmarshal(body, &response)
on a pointer to response
of type DataStructure
.
We also found that creating a types
package within the project, separated out into as many files as needed, made keeping track of types much easier and helped to avoid import cycles. The package system as a whole was very useful in keeping the codebase organized, and the ability to break packages into as many files as necessary kept our files short and readable. Neither Ruby nor Elixir make breaking longer files into shorter, more organized ones quite so simple.
In both Elixir and Ruby, to return multiple values, you have to add them to a larger data structure that holds them. In Go, functions returning multiple values isn’t just allowed, it is often the norm. As discussed above, it’s pretty typical for a function in Go to return a value or values as well as an error value.
Finally, Go’s replace
keyword in the go.mod
file makes pointing to a local or forked version of a package extremely simple and removes the need to change the name of the import every time it shows up in your codebase:
replace github.com/pkg/publicpkg => ./local/pkg
replace github.com/pkg/existingpublicpkg v0.12.2 => github.com/mypkg/forkedpublicpkg v0.0.1
Ultimately, while learning Go was a fun and enriching experience, and it’s lightweight, fast, efficient, and well-suited to relatively simple back-end systems where speed and efficiency are the major priorities, it’s not a language I think I’ll find myself reaching for very often.