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.