Hacker News new | past | comments | ask | show | jobs | submit login
SOLID Go Design (cheney.net)
161 points by davecheney on Aug 23, 2016 | hide | past | favorite | 76 comments



Definitely good stuff to think about in the piece, but even as a Go fan, I found the rhetoric to be insufferable. "Go obviously doesn’t have classes—instead we have the far more powerful notion of composition". Go's syntax may frame things differently than Java or Ruby or C++, and I don't discount the impact of syntax and abstractions on code design, _but_ just because you don't call them "classes" doesn't mean they are something entirely different.

Go types can have public and private fields, a set of associated functions which can also be public or private, and ways to extend other structs or pose as other types. So you've got encapsulation, inheritance, and polymorphism. Pretending you don't have "classes" is just silly.


It's perhaps subtle, but there is a real difference -- traditional inheritance allows the base class to call methods in the derived class; Go embedded fields don't have this property, which is a huge simplification. (You also don't have all the complexities of constructors.)


For middle of the road companies with middle of the road developers, I find SOLID is not really a great set of principles to focus on.

'S' is pretty good because it helps encourage developers to get away from huge classes that have dozens of methods.

'O' is basically useless when applied to a single codebase, because one of the worst parts of middle-of-the-road developers is when they start to realize they can extend classes. You end up with horrendously complicated and deep class hierarchies. I find that when it's time to add functionality, it's far better to just refactor and create new classes for composition.

'L' is deeply confusing, and the difficulty in understanding it is what leads to the deep and brittle class structures above.

'I' drives intermediate developers to create more and more interfaces that have 1-2 methods, which when combined with their difficulties with 'O' and 'L', just leads to confusion and high cognitive load when trying to maintain the codebase.

'D' is good.


You're not doing justice to the open/closed principle.

The open/closed principle is a lot more subtle than just "inherit from existing classes instead of modifying them". Actually, you can write open/closed compliant classes without using inheritancy at all (e.g: C++'s std::list, which allows injecting the allocator and the ordering function).

I agree, in the general case, it's impossible to write code up-front that is "closed" in an absolute way, i.e which would resist to any kind of feature request. Trying to do so will result in horribly over-generalistic code.

However, you can still spot parts of your code that always grow a little in response to some kind of feature requests (e.g each time you add a new input format, you need to add a new 'case' in this function). Then, you can decide to "close" your class against these kind of feature requests (e.g injecting the list of supported formats at construction).


I'm going to be somewhat controversial and say that I think the open/closed principle is actually a bad idea. I say that slightly carefully because whenever I shoot my mouth off on a topic that most smart people agree about, usually I find that I've misunderstood something. However, if you will indulge me...

I think the intention of the open/closed principle is to stop people breaking the back of the camel one straw at a time. It's tempting when you get feature requests to think, "Oh, it's easy. I'll just extend this class". Clearly this is something that you don't want because eventually you have a monster class with no clear purpose.

However, it is almost as easy to break the back of the camel by piling on functionality (either through inheritance or through composition). Now, you have code that is just as coupled, but we have lost the cohesion. Instead of being crappy code crammed into a single monster class, it is crappy code strewn all through the system making it virtually impossible to find.

I think the parent post is closer to the truth of what you actually want. When you get new requirements, you want to refactor your design. It should be open to obliteration and closed to a death of a thousand cuts.

The main problem with the open/closed principle, from my perspective, is that it perpetuates the idea that we should try to minimise code churn. Many people will read that and think, "Well, duh. Code churn is dangerous." But every time you minimise code churn, you add a design constraint. At best, this design constraint will lead to the pile of straw on the camel's back. At worst, the workarounds imposed by avoiding change necessitate more workarounds, slowly adding exponential growth to the complexity of the system.

What I would like to replace the open/closed principle with is a principle that says "Do not nail your APIs to the wall". They must change, if you are to maintain simple code in the face of changing requirements. Sometimes you must solidify an API, but you should always delay that decision as long as possible and you should limit the extent as much as you can.


> 'L' is deeply confusing, and the difficulty in understanding it is what leads to the deep and brittle class structures above.

Weird. 'L' is a very intuitive statement: If a property holds for all instances of a supertype, it holds for all instances of any of its subtypes. In modern terms, it could be stated as “predicates are contravariant on their arguments” [0], but the basic idea was was well understood as far back as in Ancient Greece [1].

All the issues with 'L' disappear once you properly distinguish between inheritance and subtyping [2]. As the OCaml reference manual says [3]: “Be aware that subtyping and inheritance are not related. Inheritance is a syntactic relation between classes while subtyping is a semantic relation between types.” Although OCaml has structural object types, the idea also works with nominal object types: In Java, a subclass that overrides a non-abstract method ought not to be considered a subtype of its superclass. If I told you “a zebra is kinda like a horse, except it has stripes”, could you conclude that zebras are horses?

It's amazing how effectively logic clarifies issues with programming languages.

[0] https://www.schoolofhaskell.com/user/commercial/content/cova...

[1] https://en.wikipedia.org/wiki/Term_logic

[2] http://www.cs.utexas.edu/~wcook/papers/InheritanceSubtyping9...

[3] http://caml.inria.fr/pub/docs/manual-ocaml/objectexamples.ht...


I don't see how it can be argued that 'L' is intuitive to intermediate level developers. Even for senior developers you still have to stop and think rather than trust your gut. Developers will very intuitively see the bass class as the one that handles the most common cases, and subclasses as the exceptions that "override" and vary things a little bit.

My favorite example is the Doctor that can see all patients, and the Pediatrician that sees only children. Intuition would make Pediatrician the subclass of Doctor, when it should be the other way around. [1]

[1] http://closuretools.blogspot.com/2012/06/subtyping-functions...


> Developers will very intuitively see the bass class as the one that handles the most common cases, and subclasses as the exceptions that "override" and vary things a little bit.

This intuition is wrong. Java-style classes don't handle the cases you think they handle. In particular, you don't have any guarantees at all about the meaning of non-final methods, precisely because they can be overridden by subclasses anytime! They're as good as abstract, and the implementation provided is “for reference purposes only”.

If you need stronger guarantees than that, then you must redefine the relationship between subclasses and subtypes as follows:

(0) A subclass that mutates inherited fields isn't a subtype of its superclass.

(1) A subclass that overrides inherited non-abstract methods isn't a subtype of its superclass.

But the result would be a different programming language, not the Java/C#/whatever you have today.

> My favorite example is the Doctor that can see all patients, and the Pediatrician that sees only children.

Then either (0) it isn't true that a Doctor can see all patients, or (1) Pediatrician isn't a subtype of Doctor. If you want your types to model real life, you have to go for the former.

> http://closuretools.blogspot.com/2012/06/subtyping-functions...

Contravariance of function arguments is intuitively obvious. I have no idea why anyone would struggle with it.


Let's make sure we agree on a definition of "intuitive". I am referring to the probability that an average developer will grasp the subtyping concept and understand it instinctively once explained to the point they will easily do it correctly from that point forward. I am not referring to the inherent beauty of the concept.

By that definition, my argument is that proper subtyping is counterintuitive to an average developer. Your statement that it's intuitively obvious strikes me as an opinion about the concept itself, rather than the ability of average developers to grasp it.

As for the first quote of mine, yes, I agree, that intuition I described is wrong, and that is absolutely my point. It is wrong, and yet it is very common for developers to have that wrong intuition. So it is therefore not intuitive. That's why I brought it up - that (and trying to make a pediatrician a subtype of doctor) are very common areas of confusion for people trying to understand proper subtyping.


> Let's make sure we agree on a definition of "intuitive". I am referring to the probability that an average developer will grasp the subtyping concept and understand it instinctively once explained to the point they will easily do it correctly from that point forward.

Yes, I mean that too.

> I am not referring to the inherent beauty of the concept.

Me neither.

> By that definition, my argument is that proper subtyping is counterintuitive to an average developer.

I disagree. People aren't stupid, and are able to grasp basic distinctions once they're pointed out, especially when they're newcomers to a topic. On the other hand, once they have some experience, it's harder for them to get rid of their misconceptions.

> Your statement that it's intuitively obvious strikes me as an opinion about the concept itself, rather than the ability of average developers to grasp it.

Contravariance of argument types and covariance of return types can be explained in 5 minutes to anyone with basic logical reasoning skills. People who lack such skills have no business being programmers.

> It is wrong, and yet it is very common for developers to have that wrong intuition.

That's because they've been lied to since they were beginners. It's not true that:

(0) Subclassing and subtyping are the same thing.

(1) You can understand the semantics of programming language features (such as subclasses and subtypes) in terms of natural language analogies.

> (and trying to make a pediatrician a subtype of doctor)

Trying to make Pediatrician a subtype of Doctor is correct. What is wrong is pretending that every Doctor can treat any Patient. Unfortunately, implicit subtyping in the presence of abstract types (not the same thing as abstract classes!) is rather tricky, but, assuming you don't mind upcasting manually (which is perfectly safe, albeit tedious), this is readily expressible in Standard ML:

    signature MEDICAL_ATTENTION =
    sig
      type doctor
      type patient
      (* no result is passed to the continuation,
       * we only run this for its side effects *)
      val treat : doctor * patient -> unit
    end
    
    structure Pediatrics :> MEDICAL_ATTENTION
      where type patient = child =
    struct
      type doctor = ...
      type patient = child (* assumed defined elsewhere *)
      fun treat (d, p) = ...
    end
    
    structure Cardiology :> MEDICAL_ATTENTION
      where type patient = person =
    struct
      type doctor = ...
      type patient = person (* assumed defined elsewhere *)
      fun treat (d, p) = ...
    end
    
    val child_as_person : child -> person
As can be seen, some doctors (e.g., `Cardiology.doctor`s) can treat any person, but not all.


To further clarify:

> Even for senior developers you still have to stop and think rather than trust your gut.

Not that I'm a senior developer, but I don't trust my gut. I use formal logic.


Could not agree more, I find SOLID often creates more harm than good with a lot of devs. For example:

S: Each class can only do one thing, ever! Gotta do something else? Lets add more classes that all inherit from each other!

O: Heh, no one even remembers this one, like, ever.

L: Ya, thats how inheritance works! Right? Right...

I: Hey, I know how to create interfaces! Interface all the classes!

D: OK, just stick the DB connection into the constructer and we've got the D covered, yesssssh!

Really though, IMO SOLID is somewhat overblown in many cases. It is just a set of guidelines for good OO design not any kind of iron-clad rules.


Your comment just shows that SOLID is not understood, not overblown.


"No True Scotsman" indeed

And I want to slap the "S" people whenever I see a private method inserted merely to call a library

So it's "single responsability" except for a set of arbitrary cases pulled out of who knows where.


> "No True Scotsman" indeed

that doesn't seem applicable.


I think it is both, actually.


Lets add more classes that all inherit from each other!

Can't do that in Go.


I think that S, O, I, and D are all kinda nice guidelines. But if one's code does not respect L, it is simply incorrect. It implies falsehoods.


LSP (the 'L' in SOLID) gets a bad rap only because it's typically couched in terms of classes and inheritance these days. However, if you know anything about Barbara Liskov's work, you'll know that's entirely the wrong tack to take.

If you're just using interfaces, it boils down to this: if two things implement the same interface, you should be able to substitute one for the other without it breaking everything.

So no, it's neither confusing nor difficult, except in so far as maintaining substitutability requires discipline.

'I' is something that's already encouraged within the Go community.


Kevlin Henney gives an excellent talk called "SOLID Deconstruction" with a similar set of objections https://vimeo.com/157708450


I am fairly consistent in my criticism of Go. I am also fairly consistent in my insistence that the Dependency Inversion Principle is one of the harder concepts to wrap your head around of the SOLID principles (not least because several other concepts use terms similar).

But golang's structural typing actually allows for some of the easiest demonstrations of the implementation of the DIP of any of the strongly typed languages I've used.

I find Martin's definition of the DIP counterintuitive. A description that makes more sense to me is "interfaces should be defined by the 'users' of the interface, not by the implementers".

With a language like C++ or Java, to achieve this requires either lots of adapter layers or compiler tricks. With golang it is simple.

As a for instance, let's say I'm writing a restful client library for a specific application. It is tempting to to hard code a dependency on the std lib http.Client.Do method. If instead with 3 lines of code, I define my own interface for Do that matches the std library definition. I can use the std library, or any other library that matches it (one that is faster say or implements back off).

That is one of the few areas where the golang type system is pretty cool in comparison to its peers.


But with Go's system you can't provide an implementation of an interface for a type unless you defined that type in your package. You have to use an adapter layer for that. (For example, say I want to make an interface with a function "func Show() string" that prints debug information about a type--I can't implement that function directly on http.Client, only on an e.g. "type MyClient = http.Client" wrapper.) So that makes it harder to achieve loose coupling, because you can't make your own interfaces that unify standard library types and your types unless you make your types take exactly the same methods with exactly the same signatures (problematic if you want your types to support slightly different functionality, etc.)

In a system that allows you to implement interfaces for types that you didn't define in your package, there is a little more boilerplate necessary compared to Go, but only a few lines necessary to achieve the exact same effect. In pseudo-Go:

    type Do interface {
        func Do(req *Request) (*Response, error)
    }

    // boilerplate
    implement Do for http.Client {
        func (c *Client) Do(req *Request) (*Response, error) {
            return c.Do(req) // call stdlib implementation
        }
    }

    // custom implementation of Do
    implement Do for MockClient { ... }
So Go makes a tradeoff with its structural typing: it doesn't require the small amount of boilerplate code to achieve the pattern you describe, but it prevents you from providing new implementations for types you didn't define. Reasonable people can differ as to whether the side of the tradeoff Go makes encourages or discourages loose coupling.


I think you're a little off. The only boilerplate required for your example is the interface definition. After that you can define your own implementations of the "Do" interface as suggested by the parent.

  // boilerplate
  type Do interface {
    func Do(*http.Request) (*http.Response, error)
  }

  // Custom Implementation
  type MockClient struct {}
  func (mc *MockClient) Do(req *http.Request) (*http.Response, error) {
    // MockClient type now satisfies the Do interface
  }


You're not taking this issue in account:

> you can't make your own interfaces that unify standard library types and your types unless you make your types take exactly the same methods with exactly the same signatures (problematic if you want your types to support slightly different functionality, etc.)


The original comment mentioned being able to decouple Go code from specific implementations of functionality by relying on self defined interfaces instead of specific functions. pcwalton's example seemed to confuse this point, which I wanted to clarify.

As to that point, your interface types will only match standard library ones if you're trying to unify them. In which case you probably want them to have the same signature.


> The original comment mentioned being able to decouple Go code from specific implementations of functionality by relying on self defined interfaces instead of specific functions. pcwalton's example seemed to confuse this point, which I wanted to clarify.

The problem is that you're clarifying the wrong thing, the example is confusing because it trivially proxies a built-in method but the remark is what's important to the reply. Your reply is only confusing things further by considering the flawed example but ignoring the actual point.

> As to that point, your interface types will only match standard library ones if you're trying to unify them.

No, why would you think that? You may want to "compatibilise" multiple third-party types (that some of them are in the standard library is a complete red herring) as well as first-party ones (a mock or whatever) e.g. abstract over stdlib sockets, zmq sockets and sqlite.


You're right, I'm confusing the issue. I re-read pcwalton's comment and I think understand the point they're trying to make. Not sure the last part was correct though.

> So Go makes a tradeoff with its structural typing: it doesn't require the small amount of boilerplate code to achieve the pattern you describe, but it prevents you from providing new implementations for types you didn't define.

Shouldn't this be "So Go makes a tradeoff with its structural typing: it does require a small amount of boilerplate code to achieve the pattern you describe, but it allows you to provide new implementations for types you define."?


Well no because "the pattern you describe" in that context was only implementing stuff on types you own/control, which is basically a function declaration in Go, the problem is that doesn't extend to types you don't control, Go only lets you define methods on local types (types from the same package).

By comparison the code pcwalton shows has more upfront boilerplate, you need to explicitly `implement $Interface for $Type`, but it works for both local and non-local types.

That's what e.g. Rust does, you can implement a trait on a type if either the trait or the type is local (in theory you could also do so if neither is local but IIRC that's not allowed so conflicts don't have to be handled). Which means you can do this:

    trait Do {
        fn do(&self, req: &Request) -> Result<Response>
    }

    impl Do for hyper::http::Client {
        fn do(&self, req: &Request) -> Result<Response> {
            ...
        }
    }

    impl Do for MockClient {
        fn do(&self, req: &Request) -> Result<Response> {
            ...
        }
    }
There's a higher up-front syntactic cost (in that you have to declare the trait and you have to explicitly implement it even on the local MockClient type) but in return you also can implement it on the non-local "actual" http client.


Is there any reason Go couldn't allow defining methods on types defined in another package? It seems like you could allow that, gain the property you want, and still have structural typing.

The confusing thing about this if you did implement it, is if you import a package B that implements more methods for a type in package A, do those methods magically become available to you?

One of the nicest properties about Go currently IMO is that it's always very, very clear where things are defined.


Is there a tradeoff between structural typing and allowing the addition of functions to types defined outside of your package?

They seem like orthogonal concerns to me, but maybe they aren't?


TypeScript also does this. It's neat.


There's something strange about application of the Liskov Substitution Principle in the article.

Original formulation of LSP was: subtypes should not break supertypes, i.e. caller expecting supertype should not be able to tell the difference when getting an instance of subtype. That was expressed in terms of an explicit subtype/supertype relation, like inheritance in OOP. When there's no such thing in the language, the LSP becomes (quote article):

"Two types are substitutable if they exhibit behaviour such that the caller is unable to tell the difference."

This sounds almost like a tautology. I understand that it isn't, since "substitutable" is "you substitute class X for interface Y in your code" and "observably same behaviour" is "X behaves like all implementors of Y are expected to".

In practice, X doesn't declare it implements Y. If I review a change to X I might miss that fact. If that change breaks behavior expected from Y, it's bad.

I'm not sure if this scenario is a real problem, I have written like 100 lines in Go.


In Go you might have an interface Rectangle":

    type Rectangle interface {
        SetHeight(int)
        SetWidth(int)
        GetHeight() int
        GetWidth() int
    }
Then you could conceivably have a type Square which implements this interface.

The problem is, that clients that use the Rectangle interface make certain reasonable assumptions that turns out not to hold when the Rectangle in question is a Square. Case in point: If you call SetHeight(x) and SetWidth(y), you would assume that GetHeight() and GetWidth() return x and y. But that obviously violates the invariant of a Square if x and y are not equal to each other.

This problem is what LSP aims to address.


This is sort of a classic example (with the behavioral expectations of the interface that you have stated in the text, which are not expressable as a constraint in Go code -- or in most languages where the LSP is a concern -- but which often would be documented as an expectation of the parent class in a class-based inheritance system, and would usually be expected from the naming of the class/interface) of why mutability is problematic in terms of the LSP, as its not problematic at all if the signature is:

  type Rectangle interface {
        SetHeight(int) Rectangle
        SetWidth(int) Rectangle
        GetHeight() int
        GetWidth() int
    }
Though, perhaps more than the LSP, this should be a textbook illustration of why representations of mutable objects in a program should not be named after entities in the domain whose essential identity is associated with one or more attributes that are mutable in the representation.


perhaps more than the LSP, this should be a textbook illustration of why representations of mutable objects in a program should not be named after entities in the domain whose essential identity is associated with one or more attributes that are mutable in the representation.

Yes, in the example I gave, I agree. Certainly objects such as Rectangles and Squares should be modeled as immutable value objects. Immutability solves the problem completely. The example would perhaps be better if instead of Rectangles and Squares it talked about Windows and FixedAspectWindows.


> The example would perhaps be better if instead of Rectangles and Squares it talked about Windows and FixedAspectWindows.

But, here, I think it should be quite clear that the solution is to make clear that independence of the dimensions on the two axes is not a guarantee provided by the Window interface or class. Interdependence of a particular form is guaranteed by FixedAspectWindow, and there might be a similar subtype of FlexibleAspectWindow that guarantees independence, but Window itself should not make any guarantee (and should, if necessary for clarity, expressly disclaim such a guarantee.)

But one thing I think that it is worth highlighting is that the fact that some guarantees of a type are expressable and enforceable through code does not ensure that all are, and the LSP most critically applies to those that are not (since those that are expressed and guaranteed through code won't ever be a problem), but to those that are communicated through other mechanisms, including those communicated implicitly through naming.


> including those communicated implicitly through naming

As an aside, this is an aspect of what I like about choice of names like "Functor" and "Monoid". There is very little room for ambiguity.

Further aside... In Haskell, there's still "do we include operational equivalence?" and "are we considering equivalence only up to bottom?". But at least the guarantees around the semantics for non-bottom values are reliably uncontroversial.


> The problem is, that clients that use the Rectangle interface make certain reasonable assumptions that turns out not to hold when the Rectangle in question is a Square. Case in point: If you call SetHeight(x) and SetWidth(y), you would assume that GetHeight() and GetWidth() return x and y.

I don't know that that's a reasonable assumption, it doesn't work with any fixed-aspect-ratio rectangle (of which square is but one case).

Basically, the issue here is that the interface is terrible and essentially mandates a single concrete type.


You may quibble, but what I presented is more or less the textbook example that is often used to illustrate the LSP.

I would argue that it is reasonable for any client of the Rectangle interface to make the assumption the setting the height does not affect the width and vice versa. The unreasonable part is providing such an interface to begin with. As a client of an interface, I shouldn't have to think carefully about whether or not certain assumptions I make, may be invalid in certain specific instances. If I make use of the Rectangle interface to model a resizable window in the GUI, I shouldn't have to worry how my GUI might behave if a Rectangle happens to be a Square.

If your reaction is: “but you're doing it wrong!”, we are in agreement. But to reiterate, the thing I'm doing wrong is not to make (sometimes) invalid assumptions about the behavior of Rectangle, but to make it possible to make invalid assumptions by violating the LSP.


> As a client of an interface, I shouldn't have to think carefully about whether or not certain assumptions I make, may be invalid in certain specific instances.

As a client of an interface you shouldn't make assumptions which are not guaranteed by the interface in the first place.

> If I make use of the Rectangle interface to model a resizable window in the GUI, I shouldn't have to worry how my GUI might behave if a Rectangle happens to be a Square.

The fixed aspect-ratio issue (square or not) actually applies very well to this scenario, video players will often have a fixed aspect-ratio (matching that of the video they're playing + some decoration), and while you can control the width or height:

1. there's no guarantee that they're independent

2. there's no guarantee that your setting a specific width or height will be acknowledged or unbounded

That is not only setting the width may change the height (and vice-versa), there's no guarantee `r.SetWidth(x); x == r.GetWidth()` holds. And the interface certainly makes no such promise.


As a client of an interface you shouldn't make assumptions which are not guaranteed by the interface in the first place.

Sure. But as an author of an interface you should strive to make your interface easier to use correctly. It's easy to blame the programmer who makes the wrong assumptions about your code, but if you can make your interface in way that reduces the possibility of that happening, why wouldn't you? Large software platform vendors are generally more succesful if they instead of blaming application developpers for misusing the APIs, change the APIs so that they are easier to use correctly.

The fixed aspect-ratio issue (square or not) actually applies very well to this scenario, video players will often have a fixed aspect-ratio (matching that of the video they're playing + some decoration), and while you can control the width or height [...]

A video player getting getting its window locked to the aspect of the video it is playing may be the desired behaviour. Or not. In any case, it should be the video player that makes this decision, not whomever provides the video. Imagine you have an video library API that returns a Rectangle with the dimensions of the video, which previously returned a Rectangle proper (ie. one that allows changing width and height independently). In a recent update however, it changes its internal implementation, and while the signature of the API is unchanged (it still returns a Rectangle), it now returns a Square. This has the effect of changing the behaviour of your application though nothing in it has changed!


> Sure. But as an author of an interface you should strive to make your interface easier to use correctly.

Yes?

> It's easy to blame the programmer who makes the wrong assumptions about your code, but if you can make your interface in way that reduces the possibility of that happening, why wouldn't you?

I really don't see what point you're trying to make.

> Large software platform vendors are generally more succesful if they instead of blaming application developpers for misusing the APIs, change the APIs so that they are easier to use correctly.

Well yes, that pretty much my point. The original interface as you defined it is either unreliable if you interpret it literally (and allow fixed-aspect-ratio or even fixed-size rectangles) or completely pointless if you interpret it "smartly" (since it pretty much mandates one specific implementation).

> A video player getting getting its window locked to the aspect of the video it is playing may be the desired behaviour. Or not. In any case, it should be the video player that makes this decision, not whomever provides the video.

The video player can be an application or a component, it makes no difference. If the video player is an application it changes the behaviour of your OS's maximize or fullscreen feature, or the behaviour of your window-resizing script, or whatever.


Well yes, that pretty much my point. The original interface as you defined it is either unreliable if you interpret it literally (and allow fixed-aspect-ratio or even fixed-size rectangles) or completely pointless if you interpret it "smartly" (since it pretty much mandates one specific implementation).

Are you somehow under the impression that I am advocating the example I gave as something to emulate? Your argument seems to be that the example of bad design I gave, is bad design. I'm aware of that, thank you. I'm trying to illustrate why it's bad design. It turns out that this particular bad design is common enough that it has been studied, and it has a name (well not quite, but the principle you have to follow to avoid, it has a name).

You may think that my example is contrived, but exchange Rectangle for Window and FixedAspectWindow for Square, and you have something which looks reasonable to most OOP novices. The reason the problem arises is that in classical OOP we are taught that subclasses should be specializations of the parent class. It seems reasonable therefore to make Square a subclass of Rectangle (or FixedAspectWindow a subclass of Window).


The Liskov substitution principle always holds in a type-safe language with subtyping. What is failing isn't the LSP, but rather your expectations from types. Just because a type is called “Rectangle”, it doesn't mean its instances actually behave like rectangles.

See: http://blog.tmorris.net/posts/identifier-names/index.html


There is no such thing as a "type safe language". Type safe is a subjective value judgment.


It's not subjective: The operations allowed on a Go value or object are precisely those that are sanctioned by its type. The problem for Go programmers is that Go types don't mean what they think they mean.


That's true for all programming languages. That's literally how all bugs get created. Are you claiming that there exists some language where bugs cannot happen because it is "type safe"?


> That's true for all programming languages.

I'm pretty sure that when I define abstract types in ML, I can often arrange things so that they mean what I want them to mean. For example, I can define set and heap types that are impossible to use in any other way than as sets or heaps. Of course, I still have to manually prove that my implementation is correct, but the language guarantees that, if my implementation is correct, it's impossible to misuse.

The problem with Go is that the type system is too dumb to actually enforce such things, so the names of your types are nothing but declarations of good intentions. So you might as well use a dynamically typed language, since you have nothing to lose but the verbosity.

> Are you claiming that there exists some language where bugs cannot happen because it is "type safe"?

No, I didn't say that.


The problem with Go is that the type system is too dumb to actually enforce such things

First of all, if there is one problem that is the problem with Go, I doubt that many Go programmers would agree that what you describe is it.

Secondly, what you describe as a problem with Go's type system is actually a problem with all type systems for appropriate values of such things. A type is nothing more than a statement about an interesting property of a program which can be checked by a computer. Unfortunately, there are many interesting properties that would be desirable to check, which cannot be checked by a computer. Whether or not the program halts is the most famous.

Some type systems are obviously more capable than others, but it's a spectrum. There is no divide between type safe and type unsafe.


> what you describe as a problem with Go's type system is actually a problem with all type systems for appropriate values of such things.

Not really. In a language with abstract data types, such as ML, an ordered set type only has actual ordered sets as its values. Languages without abstract types (not the same thing as abstract classes) are hobbled.

> Unfortunately, there are many interesting properties that would be desirable to check, which cannot be checked by a computer.

Who says everything has to be machine-verified? Let machines verify what they can easily verify, then verify the rest by hand. Optimal division of labor. You'd be surprised how much can be machine-verified if you arrange things properly.

> Whether or not the program halts is the most famous.

The lack of a general procedure for determining termination doesn't prevent you from proving it manually. For instance, a recursive function defined by structural induction on its argument is guaranteed to terminate. This covers most of the code I want to write in practice.


Not really. […]

You seem to have missed the last part where I wrote "for appropriate values of such things". I didn't claim that all type systems have the exact limitations of Go, but merely that all type systems have limitations.

Who says everything has to be machine-verified?

I don't know. Certainly not I. But I'm sure you agree that it would be nice if more things were.

Let machines verify what they can easily verify, then verify the rest by hand. Optimal division of labor.

Yeah, unfortunately there are some things that neither machines nor humans are great at checking. But I agree of course. My point is still simply that you are drawing the line between type safe and not type safe arbitrarily. There are type systems that are more advanced than ML's. Does that mean that ML's type system is not type safe? No, because the term type safe has no agreed upon meaning! It is, as I said to begin with, a subjective value judgment. It means simply a type system which you find comfortable and trust.


> There are type systems that are more advanced than ML's. Does that mean that ML's type system is not type safe? No, because the term type safe has no agreed upon meaning! It is, as I said to begin with, a subjective value judgment. It means simply a type system which you find comfortable and trust.

It's not about power. It's about fulfilling your promises. For instance, neither ML nor Go promises to prevent you from trying to use files after closing them. But C++ and Rust do, and it turns out only Rust actually fulfills this promise.

Go is actually type safe for single-threaded use. However, it doesn't deliver on its promise of safe concurrency. If you send an object to another goroutine, say, via a channel, nothing prevents you from mutating the same object from both the sender and the receiver, without any sort of concurrency control.

Of course, a corollary of all the above is that type safety buys you different things in different languages.


So, if I understand you correctly, your definition of a type unsafe language is one in which the type checker claims to be able to verify some statement about the program, but is actually not reliable? Is that what you mean by fullfilling promises?


Yes.


I see that I have been mistaken in my claim that there is no objective definition of what type safety means. I read the Wikipedia article after reading your previous comment, because I think I finally understood what you meant. I was convinced that type safe was a different way of saying strongly typed, but now I see that they are not the same thing.

Having now read various definitions of type safety, I still do not agree with your initial statement that type safe languages automatically precludes violations of the LSP. The Rectangle > Square problem does not happen because the programmer is not able to specify the necessary type constraints, but because she chooses not to. If by Rectangle one really means "A Rectangle which can mutate its width and height independently" and that can be encoded as a type then yes, a type safe language will enforce the LSP in this instance. But the requirement that a Rectangle has this particular behavior is not something that most programmers would think to enforce with a type, even if their language allowed it. When we model the world, we usually strive to only include what we consider to be essential, and the requirement that width and height should be independently adjustable would not be one many programmers would think essential to what is a Rectangle. But that one's model of a Rectangle has this particular feature is something that is easy to take for granted, and come to rely on once it is there. I.e. a model of a Rectangle is more useful with this feature, but without it, it is still a Rectangle.

The problem is not the types. The errors you get when you violate LSP are not type errors, but logic errors.


> The Rectangle > Square problem does not happen because the programmer is not able to specify the necessary type constraints, but because she chooses not to.

The rectangle-square problem arises because there's no such thing as a type of rectangles in Go to begin with. Rectangles are mathematical objects, and mathematical objects don't mutate or have a physical identity in computer memory. If you change the width or the height of a rectangle, what you get is a different rectangle.

> The problem is not the types. The errors you get when you violate LSP are not type errors, but logic errors.

Types and logic are two different ways to look at essentially the same thing: https://existentialtype.wordpress.com/2011/03/27/the-holy-tr...


The rectangle-square problem arises because there's no such thing as a type of rectangles in Go to begin with.

You are aware that Go and a lot of other languages allow the programmer to create new types, right?

Rectangles are mathematical objects, and mathematical objects don't mutate or have a physical identity in computer memory

Well, I would quibble with your assertion as to what mathematical objective do and don't do. Mathematical objects – like computer models – have whichever properties we assign to them, if that makes them useful. I agree that immutability is one way to ensure that LSP is not violated. And I also agree that a Rectangle is probably best modeled as an immutable value object. But LSP doesn't actually talk about Rectangles and Squares, that just happens to be the example everyone always uses to illustrate it.

Types and logic are two different ways to look at essentially the same thing

Perhaps I should have used the term modeling error instead. My point was that types can't save you from modeling the domain wrongly. The type system can't save you from using Floating Point numbers to model money for instance.


> You are aware that Go and a lot of other languages allow the programmer to create new types, right?

Yes. But they don't let you create a type whose inhabitants are rectangles: https://news.ycombinator.com/item?id=12233268

> Mathematical objects – like computer models – have whichever properties we assign to them, if that makes them useful.

Sure. You can invent your own mathematical object that's kinda like a rectangle, but can mutate. As long as you don't call it “rectangle”, there will be no confusion.

> I agree that immutability is one way to ensure that LSP is not violated.

Immutability isn't enough to solve the rectangle-square problem. For instance, Standard ML has immutable values (as well as mutable objects), but it doesn't solve the problem because the core language doesn't have subtypes to begin with. Solving the problem requires refinement types, so that squares can be defined as a refinement over rectangles.

And the LSP is never violated, because it's a tautology. It just buys you less than you think in most languages.

> And I also agree that a Rectangle is probably best modeled as an immutable value object.

The notion of a “value object” is still problematic. A “value object” still has a physical identity in computer memory. What you need to do is ditch the object and work with the value itself.

> My point was that types can't save you from modeling the domain wrongly. The type system can't save you from using Floating Point numbers to model money for instance.

Of course. But I never said that types automatically make your models correct. What I said is that you need appropriate type structure to model your problem domain correctly. It's a necessary but not sufficient condition.


Yes. But they don't let you create a type whose inhabitants are rectangles.

I have no idea you mean by that. The link you provided did not help.

Immutability isn't enough.

I'm sceptical about that. I am going to need an example of a LSP violations made with immutable types.

For instance, Standard ML has immutable values (as well as mutable objects), but it doesn't solve the rectangle-square problem because the core language doesn't have subtypes to begin with. Solving the problem requires refinement types, so that squares can be defined as a refinement over rectangles.

I'm not sure what you are saying here. Does ML prevent the problem, or doesn't it? You seem to be saying that it doesn't. But according to Wikipedia, ML is type safe (for some definition of that term), and in your initial comment, you stated that type safe languages ensure adherence to LSP.

The notion of a “value object” is still problematic. A “value object” still has a physical identity in computer memory.

Because I can get the address of it in Go? So what? That's hardly something you do by accident. And if your point is about what the language makes possible, rather than what it makes likely, I assure you that you can get in wrong in any language. The only difference is how much effort it requires.

I never said that types automatically make your models correct

You pretty much did with your initial comment that type safe languages prevent violations of LSP.

you need appropriate type structure to model your problem domain correctly

No, simply not true. You can model your domain correctly in any Turing-complete language. Types may make it easier, especially as your programs grow more complex, but types can't help you choose the correct way to model your domain.

LSP is not about type errors: The symptoms of violating it are not that operations are performed on values that are not appropriate for values of that type. In the Rectangle/Square example, calling setWidth() or setHeight() on a Square are perfectly alright as far as the Type system is concerned. The problem is simply that the resulting behaviour of the program probably not what the programmer intended. If the programmer had thought to specify -- using types -- that a Rectangle’s width and height should always be independently adjustable, then it's probably a fair assumptions that the same programmer would not have accidentally made a subtype of Rectangle that violated this invariant to begin with.


> I have no idea you mean by that. The link you provided did not help.

You can't define a type in Go whose instances are rectangles. If you define:

    type Rectangle struct {
        Width, Height int
    }
Then a so-called “Rectangle” is actually a mutable object whose current state is one rectangle, but whose state the very next moment might be another rectangle.

> I'm sceptical about that. I am going to need an example of a LSP violations made with immutable types.

I never said that ML violates the LSP. I said that it doesn't solve the rectangle-square problem.

> I'm not sure what you are saying here. Does ML prevent the problem, or doesn't it? You seem to be saying that it doesn't. But according to Wikipedia, ML is type safe (for some definition of that term), and in your initial comment, you stated that type safe languages ensure adherence to LSP.

ML adheres to the LSP trivially, because the language has no subtyping relation to begin with. Another way to view this is that every type has exactly one subtype: the type itself. Unfortunately, precisely because of this, it doesn't solve the rectangle-square problem.

> Because I can get the address of it in Go? So what? That's hardly something you do by accident. And if your point is about what the language makes possible, rather than what it makes likely,

I can't prove theorems about what a language makes “likely”.

> I assure you that you can get in wrong in any language. The only difference is how much effort it requires.

You can't take the address of a rectangle in ML. The only things that have physical identities are reference cells, and two physically distinct reference cells are always considered different values, even if their contents are the same.

> In the Rectangle/Square example, calling setWidth() or setHeight() on a Square are perfectly alright as far as the Type system is concerned.

What you're calling a “Square” is, as a matter of fact, not a square.

> LSP is not about type errors: The symptoms of violating it are not that operations are performed on values that are not appropriate for values of that type.

You are projecting your wants and desires (namely: a type of rectangles, a type of squares, and that the latter be a subtype of the former) onto the LSP, but the LSP doesn't care. The LSP only cares about the types the language actually gives you. The meaning of a type isn't in its name, but in what its instances actually do.

> If the programmer had thought to specify -- using types -- that a Rectangle’s width and height should always be independently adjustable,

Then the programmer doesn't know what a rectangle is. (Assuming you meant “adjustable using a setter”.)


The real problem is that your interface doesn't actually describe a rectangle. It describes a mutable object whose current state is one rectangle, but whose state at a different point in time might be a completely different rectangle. Lest you think otherwise: removing the setters doesn't change anything.

If you could define a type whose instances are actual rectangles, and then another type whose instances are rectangles whose width and height are equal (as a refinement of the original type of rectangles), it'd be clear as daylight that squares are rectangles.


eyeroll

I wonder how Robert Martin was implementing SOLID before the wonder that is Go was delivered unto us. And I wonder why all our buzzword-laden codebases are still knee deep in crap if SOLID is so amazingly awesome. We've been hearing about coupling and cohesion for over 50 years now. At what point does thinking in those terms become part of the problem rather than the solution?

Is it possible that Go or any other language du jour just seems cleaner because it's newer and so the cruft hasn't had a chance to form yet? Come back in 10 years, and tell me how amazing it is then.


If you've got some great conceptual framework for thinking about code quality that doesn't include coupling and cohesion or some equivalent ideas, please, share it with us all. I'm not holding my breath thought. The reason we keep talking about coupling and cohesion is that these are useful concepts that can be fairly directly applied to most day-to-day software design problems.

This was a nice, fairly short post about how to apply some generally accepted principles of good software design in a specific language. It's a worthwhile read for anyone relatively new to Go programming.


I am not against thinking in terms of coupling and cohesion but I found the following different and interesting ways to think about separating software:

https://www.destroyallsoftware.com/talks/boundaries

http://shaffner.us/cs/papers/tarpit.pdf

The common theme in both these viewpoints is that the biggest enemy is state, and who is responsible for it, rather than just responsibility on its own.


I do, but I don't want to shamelessly plug here. My point was not that these ideas are bad. My point was that they are incomplete, and pointing at them as reasons why a new language is so great is absurd -- when in fact Java supports them just as well.

I think Go is great, but I think it's great because it gets a large number of details slightly more right than the languages of the previous decade. And it will make its mistakes that languages of the next decade will learn from. SOLID is just the bare minimum ante here. Bragging about SOLID is like bragging that your language supports all kinds of data structures.


> Is it possible that Go or any other language du jour just seems cleaner because it's newer and so the cruft hasn't had a chance to form yet? Come back in 10 years, and tell me how amazing it is then.

That's the reason why. Most Go codebases 5/10 years down the road will be as obnoxious as any other 10 year old code base written in any other language. It happens with every generation of developer.

I still remember 4 years ago when Nodejs was deemed simpler, cleaner and easier to maintain than Java ... I would hate having to maintain 4 year old Nodejs codebases today.


> I still remember 4 years ago when Nodejs was deemed simpler, cleaner and easier to maintain than Java ... I would hate having to maintain 4 year old Nodejs codebases today.

Heck, I still remember when Java was simpler, cleaner and easier to maintain than C++! Well, I guess it still is …


it's a nice language BECAUSE the authors push back when asked to add this and that and all the other latest language feature fads.

They really don't get enough credit for constantly dealing with the negative hordes. Add features = language crufty. Don't add features = where's my generics (or whatever).


Exactly. That reason would fly better with me.


"Add features = language crufty."

Truly remarkable that they got it right on the first pass if that's the case. Rob Pike must be a frickin SOOPER GENIOUS.


Dunno about SOOPER GENIOUS, but I reckon he knows his stuff. He's certainly got the chops that prove it.


What's with all the Go pushing lately?


Well, 1.7 just came out, and GolangUK just happened? Not exactly surprising that a lot of posts pop up around releases or events within a specific community.


Ah that makes sense then. Interesting, I missed that but of news completely it seems. Thanks


Along with the ones mentioned by bojo, GopherCon 2016 ended a few weeks back, and the videos from the conference just started getting released five days ago. (They've got 12 up now, they put a few more up yesterday.)


Things tend to come in waves. I can't prove it, but I think it's due to an, "Oh, yea. That exists." mentality.

There were a few articles around social apps the other day that made me almost submit a "What happened to Yo!" article. Thus continuing the wave. I stopped because the article that I found was from 2014. However, it's still there. They're still enhancing it.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: