I still don't know what people mean by "obvious" code.
Yes, there are people who create a mess with abstraction. This happens in every language, in Java people create FactoryFactories, in Haskell people play type tetris, in Ruby, people abuse metaprogramming and in Go, I assume some people go overboard with code-gen.
But that said, I suspect many people, when they say, "obvious code", they mean "I can easily understand what every line does". Which is a fine goal, but how does that help me with a project that has 100ks of lines of code? I can't read every single line, and even if I could, I can't keep them all in my head at once. And all the while, every one of these lines could be mutating some shared state or express some logic that I don't understand the reason for.
We need ways of structuring large code bases. There are a ton of ways for doing so (including just writing really good and thorough documentation), but just writing "obvious" code doesn't cut it. Large, complex projects are not "obvious" by their very nature.
> I still don't know what people mean by "obvious" code.
This is one of those subjective "you know it when you see it" qualities that are going to be a function of the code itself and how well it conforms to practices you are used to. I also think that we have a tendency to not notice as much when we read code and understand what it does without having to think about it too much.
And you can get lost in Go too. You don't need a lot of language features help you complicate things.
For instance, I recently looked at some code that I had originally written, then someone else had "improved it". In my original version there was some minor duplication across half a dozen files - a deliberate tradeoff that enabled someone to read the code and understand what it did by looking in _one_ place. (This was code that runs only at startup and is executed once. It just needs to be clear and not clever).
The "improvement" involved defining a handful of new types which were then located in 3-4 different files across two new packages placed in a seemingly unrelated part of the source tree. A further layer of complexity was introduced through the use of init() functions to initialize things, which adds to the burden of figuring out which order things are going to happen in since init() functions sometimes have unfortunate pitfalls.
Yes, the code was now theoretically easier to maintain since it didn't repeat itself, but in practice: not really. Rather than look in one place to figure out what happens, you now had to visit a minimum of 3 files and 5 files in one case.
And remember those init() functions? Turns out that the new version was sensitive to which order they would get executed in. Which lead to a hard-to-find bug. Now you could say that this is unrelated to complicating things by decomposing a lot of stuff into more types, but this isn't unusual when people get a bit obsessive about being clever.
> But that said, I suspect many people, when they say, "obvious code",
> they mean "I can easily understand what every line does". Which is
> a fine goal, but how does that help me with a project that has 100ks
> of lines of code?
These are related but different problems. At the micro-scale (what you can see in a screenful in your editor of choice), consistency in how you express yourself is key. In essence the opposite of the "there is more than one way to do it" mantra in Perl. This mantra is bad advice. You should ideally pick one way to express something and stick to it - unless there are compelling reasons to make an exception. (Don't be too afraid of making exceptions. There is a fine line between consistency and obsessiveness).
If you stick to this your brain can make better use of its pattern-matching machinery. You see a "shape" and you kind of know what is going on without actually reading every line of the code.
Also, how you name things is important. When I was writing a lot of Java you could ask me the name of classes, methods, variables, and I'd get it right 90% of the time without looking. Not because I'd remember, but because I had strict and consistent naming practices so I knew how I'd name something.
(I haven't succeeded in being as consistent when I write Go. Perhaps I can get guess the name correctly 70% of the time. I'm not sure why).
Now let's look at "how does that help me with a project that has 100ks
of lines of code".
At larger scales it is really about how you structure things so you can reason about large chunks of your code. Think in terms of layers and the APIs between them when you structure your code. Divide your code into layers and different functional domains. Describe them through clear APIs with doc comments that clearly document semantics, preconditions, postconditions etc. The trick is to try to identify things that can be structured as libraries or common abstractions and then pretend that those bits should be re-usable (without going overboard).
Say for instance you are implementing a server that speaks some protocol. You want to layer transport, protocol, and state management with clear APIs between each layer. Your business logic should deal with the implementation through an API that is a clear as possible. Put effort into refining these APIs. A good opportunity is when you are writing tests. You can often identify bad API design when you write tests. If something is awkward to test it'll be awkward to use.
Also, like you would do when you write a library, give careful thought to public vs private types and functions. Hide as much as possible to avoid layer violations and to present a tighter and narrower API to the world. (Remember APIs are promises you make. You want to make as few promises as possible).
This also has the benefit that it gets easier to extend. APIs between layers are opportunities for composability. Need to add support for new transports? If you have structured things properly you already have usable interface types and unit tests that can operate on those. Need different state handling? Perhaps you can do it in the form of a decorator, or you can write an entirely new implementation.
(Look at how a lot of Go code does this. For instance how a lot of libraries, including the HTTP library in the standard library, allows you to inject your own transport. This enables you to do things the original authors probably didn't think of. I have some really cool examples of this if anyone is interested)
Over time you will probably see a lot of parts of your software that can be structured in similar ways. This allows you to develop habits for how you structure your ideas. The real benefit comes when you can do this at project or team scale. When people have a set of shared practices for how you chop systems into functional domains, layer things and design the APIs that present the functionality to the system.
So in summary: you deal with 100kLOC projects by having an understandable high level structure that makes it easy to navigate and understand how the parts fit together. When you navigate to a specific piece of code, your friends are consistency (express same thing the same way) and well documented (interface) and model types.
Years ago I came across a self-published book that taught me a lot about how important APIs are when building applications. The book was about how to write a web server (in Java). It started with Apache Tomcat (I think) and focused on the interface types.
Using the existing webserver as scaffolding it took the reader through the exercise of writing their own webserver from scratch, re-using the internal structure of an existing webserver. One part at a time.
The result was a webserver that shared none of the code with the original webserver, but had the same internal structure (same interface types). This also meant your new webserver could make use of bits and pieces from Tomcat if you wanted to. I found this approach to teaching brilliant because it taught several things at the same time: how Tomcat works, how to write your own webserver, and finally, the power of layering and having proper APIs between the different parts of your (large) applications.
I still think of the model types and the internal APIs of a given piece of software as the bone structure or a blueprint. You should be able to reimplement most of a well designed system by starting with the "bones" and then putting meat on them.
> I can't read every single line, and even if
> I could, I can't keep them all in my head at once.
Keeping 100kLOC in your head isn't useful. Nor is it possible for all but perhaps a handful of people on the planet. But if you are consistent and structured, you will know where you'd put a given piece of code and probably get there (open the right file) on the first attempt 70-80% of the time. I do. And I'm neither clever, nor do I have amazing memory that can hold 100kLOC. But I try to be consistent, and that pays off.
> And all the while,
> every one of these lines could be mutating some shared state or
> express some logic that I don't understand the reason for.
If you have 100kLOC of code where any line can mutate any state directly, you have two huge problems. One is the code base, the other is whoever designed the code base (you have to contain them so they won't do more damage). If you have gotten to that point and you have 100kLOC or more, you are really, really screwed.
I've turned down six figure gigs that involved working on codebases that were like that. It is that bad.
In Go, mutating shared state is bad practice. This is what you have channels for. Learn how to design using channels or even how to make use of immutability. There are legitimate situations where you need to mutate shared state, but try to avoid doing it if you can.
(I've written a lot of code in Go that would typically have depended on mutexes etc in C, C++ or Java, but which use channels and has no mutexes in Go. There is an example of this at the back of the book "The Go Programming language" by Kernighan et al, though this book is getting a bit long in the tooth)
If you do have to manage access to shared state be aware that this is potentially very hard. Especially if you can't get by with single mutexes or single atomic operations. As soon as you need to do any form of hierarchical locking you have to ask yourself if you really, really want to put in the work to ensure it'll work correctly. The number of people who think they can manage this is a lot larger than the number of people who actually can. I always assume I'm in the former group so I avoid trying to implement complex hierarchical locking.
I agree with most of what you say (including that not every minor duplication needs a refactoring) but I don't understand how this relates to using Go or some other language - and you definitely don't make it sound as if "write obvious code" is this easy fix that everyone knows how to do and that abstractions are always bad and if you don't use them, your code gets magically easy to understand.
It takes nuance and balancing tradeoffs to write good code, and that was IMHO missing from the comment I was replying to.
Languages are not just the language definition, but the language and how people use it. The established practices and idioms. The idiomatic approach to Go tends to be very pragmatic, minimalist and direct. And in some areas: highly opinionated.
For instance, it discourages the use of frameworks and prefers libraries. It can be hard to pin down what that means.
Frameworks tend to dictate both how you structure and how you express solutions. Your code is usually very tightly bound to a framework and it is often infeasible to switch to a different framework without a major rewrite. Your application, to a large degree "lives inside a framework".
Libraries imply a greater degree of decoupling and you should be able to rip them out and replace them with something else without having to re-architect your software. If some thought has gone into the design, the change can be as little as a few lines of initialization code. (Think well designed APIs for SQL drivers).
It is important to note that this doesn't really have that much to do with the language. I wrote Java in much the same way I write Go. Prefer libraries, avoid frameworks, prefer writing concrete classes until you a) know you really need something that has to allow for abstraction/flexibility, b) know how to do it because you have already written at least one implementation of the functionality you might want to generalize.
There is nothing stopping you from writing huge frameworks in Go. And some people really want to. They can't help themselves. Thankfully, it hasn't caught on. At least not yet. Nothing in Go dictates it has to be a more "direct" language than Java. But how key people in the Go community practice programming and how idioms evolve has had that effect. It has lead to a lot more code that is approachable.
(Be happy I didn't use C++ as an example, because there every imaginable approach from "C with classes", via "templates everything" to the more modern approach exists. All at the same time. Written by people who all think they are programming in the same language :-))
> Yes, there are people who create a mess with abstraction. This happens in every language, in Java people create FactoryFactories, in Haskell people play type tetris, in Ruby, people abuse metaprogramming and in Go, I assume some people go overboard with code-gen.
It's possible in any language and yet some languages' codebases are consistently worse than others ;)
If you create a culture of cleverness, implicitness and metaprogramming, that's what the programmers using your language will do. It's self-selection to an extent.
"I've suffered long from the Ruby ecosystem's mentality of 'look at what I can do!' of self-serving pointless DSL's and frameworks and solemnly swore to myself to stay away from cute languages that encourage bored devs to get 'creative'." [1]
"I worked at a Scala shop about 10 years ago. Everyone had their own preferred "dialect", kind of like C++, resulting in too much whining and complaining during code reviews. IMHO, the language is too complex." [2]
> And all the while, every one of these lines could be mutating some shared state
That's where the obvious code helps.
Let's circle back.
> I still don't know what people mean by "obvious" code.
The Zen of Python is a nice primer: [3]. A beautiful display of taste right there.
A few concrete examples:
- "Explicit is better than implicit."
Explicitly returning errors means we get to see every single point at which something could error out - explicit, as opposed to exceptions that could implicitly propagate from any line of code, with no way to tell.
Preferring pure functions - a pure function is a black box with a clearly drawn boundary line of input->output. Trivial to reason about in isolation.
No automatic type conversions.
No global state - any part of the code could change it.
No metaprogramming - you've learned Ruby but now some parts of the language have been changed to mean something completely different!
"The syntax has so many ways of doing things that it can be bewildering. As with Macro-based languages, you are always a little uncertain about what your code is really doing underneath." [4]
- "There should be one-- and preferably only one --obvious way to do it."
Uniform code. Iterating through an array always looks the same, so if the code you're looking at does it differently, you'll pay attention.
I'm not sure why you think that quoting random HN users proves anything except personal opinions of specific people? These are fine, but other people have other opinions.
I don't oppose the "principles" you've quoted, but they would just as easily apply to e.g. Haskell (maybe except for the "there's only one way to do it" which however has never been true of any language, including Python).
I feel like you missed my larger point. It's not terribly difficult to write code that you can understand line by line. It's incredibly hard to write a huge code base in a way that you can reason about many code paths simultaneously, however. That's where the abstractions start to make sense.
I don't understand why people think that those facilities were created just to piss people off? People were facing real problems. Yes, sometimes the cure is worse than the disease. Use abstractions judiciously and by employing common sense. That doesn't mean you should never use them.
I've seen over- and underabstracted code (as well as just plain wrongly abstracted code). Both of these situations really suck.
Honestly, what annoys me a bit here is your smugness. It seems as if you feel you've figured out how to write good code, and all the other idiots who use Ruby, Java, etc. haven't. But I don't believe you. Nobody in this industry knows how to write "good" code. I don't even think we know what "good" code is. The most we can do is try our best, learn about better ways to do things, discuss approaches and use our judgment.
> I'm not sure why you think that quoting random HN users proves anything
Does it have to?
How do you prove that what someone expresses is something other than opinion when it comes to programming practices? This isn't a field that is easy to quantify or distill into unquestionable truth. To approach anything nearing proof we'd need data from which clear conclusions can be drawn. A look at scientific publishing on a lot of these topics suggests that "proof" is going to be hard to come by for a lot of the things discussed here.
> but other people have other opinions.
Not all opinions count equally to all people. In fact, most people's opinions don't matter. However we do tend to value the opinions of people who are able to properly articulate reasonable arguments based on demonstrable results or experience.
I'm not looking for rigorous proof, but these are just cherry-picked example quotes instead of a coherent argument. You could just as well quote people who have experience dealing with code that is "not clever enough", e.g. 1000 line files without any internal structure. What does that show?
Writing code is about tradeoffs and not about pithy truisms lime "code should be obvious".
I'm afraid "writing code is about tradeoffs" is just as much of a truism. It's not going to help anybody write beter code. You need to get into the specifics.
The difference is that I didn't presume I could give anyone easy advice about how to write good code.
The only thing you can do is gain lots of experience, constantly question if there are better ways to do things, read about ideas others have had (without getting religiously attached), make your own mistakes, hopefully learn from them, overcorrect, then learn from your overcorrections and so on. I'm afraid there's not really an easier way.
On the other hand "write obvious code" makes it sound as if there's an easy way to do it, and everyone who doesn't write "obvious code" is just deluded, showing off, or something like that.
> On the other hand "write obvious code" makes it sound as if there's
> an easy way to do it, and everyone who doesn't write "obvious code"
> is just deluded, showing off, or something like that.
There is nothing easy about writing code that is obvious to other people. After all, the point of the exercise is how you communicate ideas clearly to people with the goal of amplifying their productivity. That takes time, empathy, and a willingness to study how other people understand things and accepting when you are not getting through to them.
Most programmers are not good at this. Worse yet, most programmers won't try to be good at this and instead go looking for simple truths. At best, most programmers obsess over "best practices" without really asking themselves if these are the best way to communicate a given idea or if there is a clearer way. To dare to do what's better, yet respect when "better" isn't necessarily better for other people.
(Erik Spiekermann, a designer of typefaces, made an observation that is useful with regard to the last statement. Erik has no love for the font Arial. He doesn't think it is a very readable font. However, he also points out that it is a font most people are so familiar with that even though he thinks its design is poor, it is actually a good functional choice because so many people are used to it that their familiarity makes it easy to read text typeset in Arial).
If your attitude is that it isn't worth trying because it isn't easy to do, then I'm sorry for you, but I think your value to an employer will be limited to only the output you can produce yourself. The biggest potential for a programmer is in their ability to amplify other programmers. And that starts with being able to communicate clearly. Both at the micro level, in code, and at the macro level, in terms of clear structure, abstraction and architecture.
The best way to ensure you don't evolve into a programmer who is able to amplify other programmers, and provide value beyond your own immediate output, is to not even try.
Before you get too upset I think you should practice what you preach. I can only judge from what you write. I don't know you. And you have made assumptions about what I think or say, which I then politely, and at some length, have tried to clarify and explain to you. When you continue to argue in a manner that, at least to me, signals you are not interested in actually understanding what I'm saying: that's on you. You are obviously offended, and I would probably care, if it wasn't for your whiny, self-righteous attitude.
So you claim that beyond some magic size limit a codebase cannot be easy to understand. Well, quantify it. At what number of lines of code does a codebase pass from the domain where it is possible to easily understand it to where it is impossible?
I suspect you didn't pay any attention to what I wrote about how people go about ensuring that large codebases maintain readability. That saddens me a bit because it means you probably aren't interested in learning anything.
This is just an incredibly bad faith reply, and an insulting one at that. I don't know why you felt the need to cross into personal territory, but I will make a mental note not to engage with you again.
PS, please check my comment history before claiming that "I'm not interested in learning anything".
> I'm not sure why you think that quoting random HN users proves anything except personal opinions of specific people? These are fine, but other people have other opinions.
They support my argument so I included them. I don't see a practical difference between a "random HN user" and a random blog post or anything else. A good idea is a good idea, regardless of the medium.
---
> I don't oppose the "principles" you've quoted, but they would just as easily apply to e.g. Haskell
Possibly, I threw it together pretty quickly, so it's not very thought-out. I'm also not familiar enough with Haskell to know what you mean.
---
> (maybe except for the "there's only one way to do it" which however has never been true of any language, including Python).
It's not binary, but a spectrum. No language has just literally one way to express each concept. However, Python is definitely further towards the "one way to do it" end of the scale. Perhaps not as much these days as it used to, but still far more than for example Ruby or Perl.
---
> I feel like you missed my larger point. It's not terribly difficult to write code that you can understand line by line. It's incredibly hard to write a huge code base in a way that you can reason about many code paths simultaneously, however. That's where the abstractions start to make sense.
I thought I adressed that; everything I mentioned - explicit error returns, pure functions, no global state, and no metaprogramming - all show their true worth in large and/or unfamiliar codebases.
---
> I don't understand why people think that those facilities were created just to piss people off? People were facing real problems. Yes, sometimes the cure is worse than the disease. Use abstractions judiciously and by employing common sense. That doesn't mean you should never use them.
I'm not sure what exactly you mean by abstractions, so this is hard to respond to.
---
> I've seen over- and underabstracted code (as well as just plain wrongly abstracted code). Both of these situations really suck.
Both suck, but as far as I can tell, erring on the side of underabstracting is better. Some random references (not HN comments ;>): [1] [2].
> Honestly, what annoys me a bit here is your smugness.
;) I can either post something quick but smug or spend hours polishing it until it's as bland as can be. I prefer the quick and authentic approach.
---
> It seems as if you feel you've figured out how to write good code,
Not even close.
---
> and all the other idiots who use Ruby, Java, etc. haven't.
I'm definitely not calling people who program in <x> language idiots. There are far too many factors at play to be able to make such a broad judgement.
---
> Nobody in this industry knows how to write "good" code. I don't even think we know what "good" code is.
I'd certainly hope that in the last half a century of programming we have at least learned something! A video (and book) you may enjoy: [3].
---
> The most we can do is try our best, learn about better ways to do things, discuss approaches and use our judgment.
> I thought I adressed that; everything I mentioned - explicit error returns, pure functions, no global state, and no metaprogramming - all show their true worth in large and/or unfamiliar codebases.
I agree with all of those. I try to avoid these things whenever I write code (in rare cases, there are valid reasons for using them, but I agree they are overused).
But that still leaves tons of room for different ways of writing code, and it's very hard to say which one of these is "obvious". Obvious to whom? Some people want to have large methods, so they can see everything at a glance, others prefer to have more smaller function so they can see the high-level picture before they see the details. Which one of them is right? I can't say - it depends on the person, on the problem, and on many other factors.
What is missing from your list is "don't use advanced language features". You can do all the things you mentioned and still use "advanced" language features. And the debate about Go is often about how (presumably) the absence of "complex" features (something which is IMHO a bit subjective) makes code more "obvious".
But you can have generics (or, more broadly, polymorphism), interfaces, higher order functions, proper algebraic data types, etc. and still uphold all the features you've quoted. That's what I meant with the Haskell comment (but supposedly the same would be true for e.g. Lisp).
Here, Rich Hickey (the creator of Clojure) makes a point that resonates very well with me: namely that simplicity (which is what we desire) is not the same thing as easiness, and that pursuing the latter can often come at the expense of the former. In other words, if you use "advanced" features the right way, it can help create code that may require more concepts, but is still simpler to reason about.
I've never taken this blog post to mean that abstractions are wrong. She does say that a bad abstraction is worse than no abstraction - but, IMHO, also that the right abstraction is even better than that. BTW, Sandi Metz is a Rubyist.
> Both suck, but as far as I can tell, erring on the side of underabstracting is better.
Maybe you haven't had the fortune of working with code written by non-developers (e.g. data scientists). But in any case, my most common experience is that most abstractions are simply wrong. Code that belongs together is spread out over 5 files, and these in turn don't have a single purpose, but end up doing too many things at once. Of course, I've also seen overabstracted "every class has an interface" nonsense. And I've seen huge functions that would have benefited from some internal structuring.
> ;) I can either post something quick but smug or spend hours polishing it until it's as bland as can be. I prefer the quick and authentic approach.
Well, this comment that I'm replying to doesn't seem bland to me. You elaborating your point of view makes for more interesting discussion - at least in my view.
Yes, there are people who create a mess with abstraction. This happens in every language, in Java people create FactoryFactories, in Haskell people play type tetris, in Ruby, people abuse metaprogramming and in Go, I assume some people go overboard with code-gen.
But that said, I suspect many people, when they say, "obvious code", they mean "I can easily understand what every line does". Which is a fine goal, but how does that help me with a project that has 100ks of lines of code? I can't read every single line, and even if I could, I can't keep them all in my head at once. And all the while, every one of these lines could be mutating some shared state or express some logic that I don't understand the reason for.
We need ways of structuring large code bases. There are a ton of ways for doing so (including just writing really good and thorough documentation), but just writing "obvious" code doesn't cut it. Large, complex projects are not "obvious" by their very nature.