Disallowing import cycles. This really limits how useful packages are for modularising a project, since it encourages putting lots of files in a package (or having lots of small packages, which can be just as bad if files which should be together are not).
Maybe I’m in the minority here but this has forced me to design packages in a better / more modular fashion that really shows up later in a projects lifecycle in spades.
The same restriction actually applies to Rust. You can’t have import cycles between crates, which are Rust’s compilation units. Rust modules are an extra tool for code organization within a crate that really has no Go equivalent, and recursive imports in this case are occasionally useful.
A real comparison here is difficult to make, since in Rust, you might not create as many crates as you would packages in Go. But it’s pretty likely that you’ll create some.
It’s also further complicated by the fact that a Go module is analgous to a Rust crate in terms of the unit of distribution, with the crucial distinction that a Rust crate is also a compilation unit. But in Go, a module may of course be built from many compilation units.
Error handling also causes repetition. Many functions have more if err != nil { return err } boilerplate than interesting code.
Whenever I see return err, I see a missed opportunity. Every error return is a chance to add additional context to the error, stringing together the exact sequence of events leading to the error directly into the error message. Done well, you end up with a lovely semantic “stack trace” that completely identifies the situation leading to the error.
You could have logs full of ERROR: connect timed out, or you could have:
ERROR: failed to zing the bop "abcd": failed to fetch bibble diddle: failed to initialize HTTPS connection to "https://bleep-bloop.domain": timed out waiting for DNS response
yes, returning an error without wrapping it is, nine times out of ten, Doing It Wrong. At my company we have a package that is similar to this one that contains various tools for manipulating and handling errors: https://github.com/pkg/errors
also, after 8 years of programming Go, I strongly dislike stack traces now. Stack traces do not tell me a story of how an error happened, they give me homework of how to divine that story by reading the source code and running the program in my head. If you don’t have the source code, or if you’re running many versions of many programs, the utility of the stack trace further decreases. My Go code consistently has the best error-handling semantics of any code that I actually put into production.
ah! Nice! Yeah, that’s a useful improvement. Wasn’t clear from the comment before how 1.13 changed it; I thought trousers was saying that 1.13 added fmt.Errorf. Thanks for the clarification :)
In addition, depending on the case where this return is located, the “time out” info may already be included in err, so it might even be potentially removed from the error message; personally, I recently start to lean towards, in each function, including what “extra” info this function can add to the error, about context it controls, and the function itself; so, I might for example write instead something like:
return fmt.Errorf("waiting for DNS response: %w", err)
or maybe even:
return fmt.Errorf("retrieving DNS record %q: waiting for server response: %w", recordName, err)
This is based on how errors in stdlib are composed, e.g. IIRC an error from os.Open would often look like: "open 'foobar.txt': file not found"
I haven’t yet used the “new” errors packages (e.g., github.com/pkg/errors) in anger yet. How do they work with respect to checking if an error in the chain was of a specific type (e.g., os.IsNotExist() or io.EOF or converting to a specific type)?
Philosophies of the two languages are very different. Go is definitely small and easy to pick up, but I miss both low-level power and high-level abstractions of Rust. Go is a “medium-level” language all the time.
That is surprising, I would never willingly use that monstrosity. Type theory is sufficiently sophisticated to do most of reflect at compile time, why would I want all those runtime checks polluting my code? IMO if you reach a use-case where you need reflect your project has outgrown go.
You can’t write functions with a receiver in a different package, so even though interfaces are ‘duck typed’ they can’t be implemented for upstream types making them much less useful.
am i mistaken or does embedding the upstream type this, but in reverse? the composition is often overlooked in go, while it is one of the best things. not being allowed to fiddle around in other packages is a good restriction as this is a symptom for other problems.
Yes, embedding types is one way to solve this in Go. Rust and Go are very different in this regard.
In Rust, it’s common to extend a type with additional functionality using traits so you don’t need to convert between the types. In Go this isn’t possible. See this for an example. The basic AsyncRead provides low level methods and AsyncReadExt provides utility functions. It means that if someone else implements AsyncRead for a type, they can use any of the AsyncReadExt methods on it (provided AsyncReadExt is imported). Something like that just isn’t easily possible in Go to the same level because it’s only possible in Rust due to generics.
if you extend a type, can your extension affect what the original code was doing? Part of the motivation for Go’s typing system is that it’s designed to avoid the fragile base class problem. As someone with little Rust experience, it’s not clear how extending types in Rust avoid a fragile base class scenario.
The original code is unaffected. The alternative implementation is only available to code that uses the implemented trait and Rust doesn’t allow conflicting method names within the same scope, IIRC, even if they’re for two different traits on the same type.
One could argue Rust programs could suffer from the fragile base class problem via default method impls on traits. But it’s not something I’ve experienced much (if at all) in practice. Rust doesn’t have inheritance, so you don’t really wind up with complex inheritance hierarchies where this sort of complexity is difficult to manage.
I’ve seen it happen with extension libraries like itertools that want to add functionality that makes sense in the base trait. It’s always possible to avoid it by using UFCS, but at that point you already lost method chaining and might as well use a free function.
I don’t know rust, but isn’t that kind of having io.Reader in go and other types which take io.Reader implementations and implement other functionality on top of that? Like bufio.Reader?
Wrapping a type to implement an interface is somewhat similar. But in Rust, you do not have to write a wrapper to implement traits. E.g. have a method to reverse code points of a String you can just define a trait and implement it directly for String:
After that you could just call somestring.reverse_codepoints() when the trait is in scope. It’s often more convenient than wrapping, because you do not have to wrap/unwrap depending on the methods you need (or write delegation methods).
That said, there are some limitations in that the orphan rules have to be satisfied. Very roughly, this means that the implementation should be defined in the same crate as the trait or as the type the trait is implemented for. Otherwise, two different implementations could be defined for the same type. If you cannot satisfy the orphan rules (e.g. because the type and trait come from another trait), you do need a wrapper type.
It’s often more convenient than wrapping, because you do not have to wrap/unwrap depending on the methods you need (or write delegation methods).
usually the wrapped versions tend to be used from there on (at least the way i use them ;), so that’s not really an issue for me. i like the verbosity of go, which may be a bit unusual, but i like that things are written down explicitly.
I had forgotten about interfaces somehow. Yes, sort of. But you’re limited to wrapping stuff one level at a time and you have to actually make new wrapper types.
Maybe I’m in the minority here but this has forced me to design packages in a better / more modular fashion that really shows up later in a projects lifecycle in spades.
I agree, every import cycle I’ve seen has been a mistake of design.
The same restriction actually applies to Rust. You can’t have import cycles between crates, which are Rust’s compilation units. Rust modules are an extra tool for code organization within a crate that really has no Go equivalent, and recursive imports in this case are occasionally useful.
A real comparison here is difficult to make, since in Rust, you might not create as many crates as you would packages in Go. But it’s pretty likely that you’ll create some.
It’s also further complicated by the fact that a Go module is analgous to a Rust crate in terms of the unit of distribution, with the crucial distinction that a Rust crate is also a compilation unit. But in Go, a module may of course be built from many compilation units.
Technically, I think it’s actually possible to work around this by using the dot alias in the import.
https://github.com/golang/go/wiki/CodeReviewComments#import-dot
Yeah, I know it’s frowned upon (with good reason because circular deps are a bad idea). I was merely pointing out that it is possible :)
Whenever I see
return err
, I see a missed opportunity. Every error return is a chance to add additional context to the error, stringing together the exact sequence of events leading to the error directly into the error message. Done well, you end up with a lovely semantic “stack trace” that completely identifies the situation leading to the error.You could have logs full of
ERROR: connect timed out
, or you could have:ERROR: failed to zing the bop "abcd": failed to fetch bibble diddle: failed to initialize HTTPS connection to "https://bleep-bloop.domain": timed out waiting for DNS response
yes, returning an error without wrapping it is, nine times out of ten, Doing It Wrong. At my company we have a package that is similar to this one that contains various tools for manipulating and handling errors: https://github.com/pkg/errors
also, after 8 years of programming Go, I strongly dislike stack traces now. Stack traces do not tell me a story of how an error happened, they give me homework of how to divine that story by reading the source code and running the program in my head. If you don’t have the source code, or if you’re running many versions of many programs, the utility of the stack trace further decreases. My Go code consistently has the best error-handling semantics of any code that I actually put into production.
That’s just assembling a stacktrace by hand.
I’m not a go programmer, so a stupid question: How would the error handling code look then? Like this?
Or something more complex? Would this affect the function signature of everything in the call chain?
Go 1.13 added error wrapping, so you can now do this:
that’s been around since 2010; it didn’t actually take the Go team a decade to come up with that.
https://github.com/golang/go/commit/558477eeb16aa81bc8bd7776c819cb98f96fc5c1
The
%w
is what’s new in 1.13, permitting e.g. errors.Is.ah! Nice! Yeah, that’s a useful improvement. Wasn’t clear from the comment before how 1.13 changed it; I thought trousers was saying that 1.13 added fmt.Errorf. Thanks for the clarification :)
In addition, depending on the case where this
return
is located, the “time out” info may already be included inerr
, so it might even be potentially removed from the error message; personally, I recently start to lean towards, in each function, including what “extra” info this function can add to the error, about context it controls, and the function itself; so, I might for example write instead something like:or maybe even:
This is based on how errors in stdlib are composed, e.g. IIRC an error from os.Open would often look like:
"open 'foobar.txt': file not found"
I haven’t yet used the “new” errors packages (e.g., github.com/pkg/errors) in anger yet. How do they work with respect to checking if an error in the chain was of a specific type (e.g., os.IsNotExist() or io.EOF or converting to a specific type)?
errors.Is(err, os.ErrExist)
There are some other helper functions so that you can quickly handle wrapped errors.
Philosophies of the two languages are very different. Go is definitely small and easy to pick up, but I miss both low-level power and high-level abstractions of Rust. Go is a “medium-level” language all the time.
Interestingly enough, in my experience the hardest thing to teach juniors is that they really don’t need
reflect
.I guess they didn’t become developers to do repetitive work all day. :)
That is surprising, I would never willingly use that monstrosity. Type theory is sufficiently sophisticated to do most of
reflect
at compile time, why would I want all those runtime checks polluting my code? IMO if you reach a use-case where you needreflect
your project has outgrowngo
.am i mistaken or does embedding the upstream type this, but in reverse? the composition is often overlooked in go, while it is one of the best things. not being allowed to fiddle around in other packages is a good restriction as this is a symptom for other problems.
Yes, embedding types is one way to solve this in Go. Rust and Go are very different in this regard.
In Rust, it’s common to extend a type with additional functionality using traits so you don’t need to convert between the types. In Go this isn’t possible. See this for an example. The basic AsyncRead provides low level methods and AsyncReadExt provides utility functions. It means that if someone else implements AsyncRead for a type, they can use any of the AsyncReadExt methods on it (provided AsyncReadExt is imported). Something like that just isn’t easily possible in Go to the same level because it’s only possible in Rust due to generics.
if you extend a type, can your extension affect what the original code was doing? Part of the motivation for Go’s typing system is that it’s designed to avoid the fragile base class problem. As someone with little Rust experience, it’s not clear how extending types in Rust avoid a fragile base class scenario.
The original code is unaffected. The alternative implementation is only available to code that uses the implemented trait and Rust doesn’t allow conflicting method names within the same scope, IIRC, even if they’re for two different traits on the same type.
You can. But when you try to call such a method, if it is otherwise ambiguous, then Rust will yield a compiler error. In that case, you have to use UFCS (“universal function call syntax”) to explicitly disambiguate: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=a602c67af78a73308808e9a45a51ead4
One could argue Rust programs could suffer from the fragile base class problem via default method impls on traits. But it’s not something I’ve experienced much (if at all) in practice. Rust doesn’t have inheritance, so you don’t really wind up with complex inheritance hierarchies where this sort of complexity is difficult to manage.
I’ve seen it happen with extension libraries like
itertools
that want to add functionality that makes sense in the base trait. It’s always possible to avoid it by using UFCS, but at that point you already lost method chaining and might as well use a free function.https://github.com/rust-lang/rust/issues/48919
No, because traits are only available to use if they’re imported. So you’re not actually modifying the actual type, but extending it.
I don’t know rust, but isn’t that kind of having io.Reader in go and other types which take io.Reader implementations and implement other functionality on top of that? Like bufio.Reader?
Wrapping a type to implement an interface is somewhat similar. But in Rust, you do not have to write a wrapper to implement traits. E.g. have a method to reverse code points of a
String
you can just define a trait and implement it directly forString
:After that you could just call
somestring.reverse_codepoints()
when the trait is in scope. It’s often more convenient than wrapping, because you do not have to wrap/unwrap depending on the methods you need (or write delegation methods).That said, there are some limitations in that the orphan rules have to be satisfied. Very roughly, this means that the implementation should be defined in the same crate as the trait or as the type the trait is implemented for. Otherwise, two different implementations could be defined for the same type. If you cannot satisfy the orphan rules (e.g. because the type and trait come from another trait), you do need a wrapper type.
This seems dangerous, since now users of the original type may not realize it has suddenly grown new talents.
It actually doesn’t, because the function is not associated with the type. It’s associated with the pair of (type, trait).
You have to import the trait ReverseCodepoints before you can call it.
Or, worse yet, that existing talents may have been redefined. (Is that possible?)
Nope - and even if you could override an
impl
, the orphan rule would stop you overriding impls you don’t own.👍 Good to hear.
thanks for the explanation!
usually the wrapped versions tend to be used from there on (at least the way i use them ;), so that’s not really an issue for me. i like the verbosity of go, which may be a bit unusual, but i like that things are written down explicitly.
I had forgotten about interfaces somehow. Yes, sort of. But you’re limited to wrapping stuff one level at a time and you have to actually make new wrapper types.
i kind-of like that in go i have to add new types for new functionality, but i see why traits may be good (without having written rust yet..)
“It is an incremental improvement, rather than doing something radically different”
I believe that was an explicit design goal.