Chig Beef
Posted on April 1, 2024
Intro
I have no idea what happened, but the amount of code I've written per month went crazy. We're only going to be counting Go code, because it's my main language and the most relevant.
Just by sheer volume, what have I learned? Probably something you'd hope, so I'm going to reflect, and hopefully you'll skip my mistakes!
Pick One Case
Snake case, or camel case. Never both, this was my first mistake. My thinking was that I could name my variables using camel case, and my functions with snake case. This way, I always knew which was which. This was a terrible idea. If you think you've found a smart reason to mix them, you haven't. Don't know which one to use? Read an open source project, see what they use for that language. For example, I would use snake case for Python, because that's what most people use, but from now on I'm using camel case for Golang.
Interfaces
If you're not careful, interfaces can be as dangerous as inheritance. This is a developer time danger, you can waste a lot of time trying to figure out how to get everything to fit together. This doesn't mean it isn't the right abstraction, but it shouldn't be your first reach. Throughout my 6k line project I only use one interface, which is for allowing different applications to be understood by the desktop easier.
Implement Modularization
This project has an interpreted language within itself named Slither. Initially, when creating this project, all my code was in one folder, and when I wanted something to about Slither, I would prefix it with "Slither." I know, that's a pretty bad design decision, but what was the alternative?
Firstly, I put all the code for Slither in a dedicated folder. I was then able to access this code like so.
"CompNerdSim/slither"
It was that simple! Now in Slither's code I could remove that stupid prefix. A good thing to note here is that I named the folder "slither". The module (go.mod) is also named "slither".
Use Arrays
If you ever know the exact length of a slice every time, use an array. For example, for a position I would use an array with compile time length of two, rather than a slice. Why? Firstly, if I ever need a third value, it forces me to think about how to implement it more. These are the abstractions I like, the ones that work for your current use case, but give you a hard limit. The reason I like this is because if you break the abstraction you're also breaking scope, you literally were not anticipating this change. Therefore, maybe it's a good idea to write a few dot points in your design document? Think about a new best course of action, and how this new solution will affect the rest of your code. This is also the reason I use uint8
a lot (or byte
), because if I'm overflowing past 255, or using negative values, I need to rethink what's happening, does it actually need to be a generic int
?
Should You Comment?
This is a controversial topic. On one hand, it's good to explain your code, but on the other, comments can easily go out of date and not make any more sense. So what has my solution with comments been? Well let's look at an example of one of my comments.
// We start on the IDENTIFIER, so by
// moving back one we're back on the
// accessor. We move back one more time
// and we land back on the last
// identifier or call. We could've put
// down a marker, but we know it's
// always 2 tokens back, so it's
// actually better to have it this way
First thing to note is that I keep the width of my comments to 40 columns (not including indentation). Now, whether this is 30 columns, 60, or even 80, it doesn't actually matter, as long as you can fit a good amount of information into it, and not go over the screen width. You also have to keep in mind that other people reading your code not only have different screen widths, but also font sizes. Therefore, limiting you comment width can lead to less confusion from those people. Also notice that I'm focused on why the code is the way it is. 2 sequential rollbacks would look weird? Why not just set a marker and jump back that distance instead? Well, it's the same as my interface reason, the abstraction currently works, if it breaks, I need to look back at this code anyway, it's just an early warning sign.
I know what you're thinking, that's a lot of commenting, but there are very few of these comments. They are large, but they're few in number.
Large Functions
How large should your functions be? Well, it doesn't actually matter too much, as long as your code is readable, just don't be stupid about it. One thing you should be using is DRY code, but how DRY? I would say quite a wet version, repeat yourself a lot, and only then should you abstract that into its own function. Repeat yourself, unless it's a very well-defined tasks that easily abstracts into a function. Here's an example.
func (sp *Parser) check_token_choices(linePos int, tokenKeys []string) Error {
for i := 0; i < len(tokenKeys); i++ {
if sp.curToken.code == TokenCodes[tokenKeys[i]] {
return Error{""}
}
}
errText := ""
for i := 0; i < len(tokenKeys); i++ {
errText += tokenKeys[i]
errText += " or "
}
errText = errText[:len(errText)-4]
return newError(linePos, "expected "+errText+", got "+sp.curToken.text)
}
Firstly, notice the use of snake case (':
But other than that, I was noticing that there are lots of situations where multiple different tokens are possible. For example, an expression can start with an identifier, or a literal of any type. This choice behavior was popping up all over my parser, so I abstracted it. It's not only easier to use, but it also makes my other code throughout the parser more readable, because I'm not using as much vertical space to decide on a token.
You Don't Need Your Own Errors
Use error
, don't create another struct for it. The first reason for this is because then you can't use err != nil
check, you have to use an even more obtuse check, which, in my case, is err.value != ""
. Looks bad, was a terrible design decision, did I gain anything from it? Not really, no. Now maybe there are times when you do need to know the difference between types of errors, but for me, I didn't, so it ended up just being a waste of time.
Don't Rely on Type Inference
I once had a bit of beef with variable shadowing, and it still manages to get me, but I've started trying me best to get around it. Firstly, if you know the type of a variable, and you're sure about its scope, why not use a line to define it like so?
var x int
Now you know you should not be using :=
when assigning to this variable at all, and if you have, you've messed up. Sure, it's a bit obtuse, and c-like, but I honestly think this is an under-rated Go downfall. All you need to do is predefine your variables when you come across a well defined task. You can still use type inference, I use it very often, jut be wary about where you're using it.
Create Constructor Methods
Do you have a very set way that a certain struct is created? Be smart, create an init
method. Say you have a Ball
type, and this is how you create them.
b := Ball{7, 3}
That's fine, until you're always creating the ball that way with those properties. It's very prone to mistakes and ageing.
b := newBall()
Isn't that better? Just create a function that does exactly what the first one did. This is a very simple example, but you can also imagine this on larger structs. Another great part about this is that you can pass in some properties you want to be changed into that function. This allows you to specify the exact variability of the struct that you want.
Keep Main Small
I've had projects where main.go
ends up getting a bit too large, and the reason is that my code isn't modularized smartly! I have a rule, if main.go
is 1,000 lines, I've messed up and I need to change something. That's an extreme case, main.go
should actually be around 100 lines. Why? main
isn't a very descriptive file name, and for this reason any code in this file is described by the file. I think that should go without saying, but I do think it's easy to chuck functions we don't care about into main.go
.
Usually for me, each file contains one struct, which has all the code relating to it in that one file, and that is regardless of a 10 line file or a 2,000 line file (obviously if it gets to 2,000 lines I'll start looking at why it's that long). I have a few exceptions to this. In my games, all audio processing is less than 200 lines, so mashed into one file, it still makes sense. What I wouldn't do is fit an entire compiler into one file. One file means if I want to work on the parser, I have to scroll to it. With more files, I can just make my way to parser.go
, and all I need is right there.
Conclusion
What do you think? I think you can tell I made some extremely stupid mistakes, but are my new solutions good? As subjective code is, there are objectively bad design decisions, and for me it took 6,000 lines to figure that out, and I could still be wrong, you're definitely not going to know unless you start coding.
Posted on April 1, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024