You Gonna Hate This, or a Tale of How a Good Code Should Look
Good day everyone. Some time ago I spoke to students on the topic "What we expect from good code" and decided to duplicate it here. In the process of translation, the text has changed somewhat, but the essence remains the same. The article turned out to be simple (and certainly not complete), but there is rational grain here.
The code should work
Let's talk about what we expect from the code. Well, for starters, it should work.
It sounds obvious, of course, but each of us once tried or successfully launched a code that is not even going, so do not laugh. The second point is that the code should work correctly with incorrect situations. That is to catch mistakes. But let's go back to the first point and talk a little about it.
Periodically, I get a task that I have no idea how to do. That is, in general (I try to look around and constantly try something new). And then I immediately pulls to write some kind of abstraction, some kind of infrastructure in order to delay the moment of real work. So, this is wrong. The code should work. I know that I repeat, but this is an important point. If you don’t know how to solve a problem, don’t rush to create interfaces, modules, and that’s all. This is a bad idea, and it will end up running out of time for you, and you will not get anywhere. Remember, badly working code is many times better than good, but not working code.
There is an old parable about two software companies that made the same product. The first one did it anyhow, but the first one entered the market, and the second one did everything perfectly and was late. As a result, the first campaign managed to win the market and bought the second company. This is a little about something else, but the basic idea is still the same. First we solve the problem, then we make the code beautiful.
In general, first make a working prototype. Let him be lame, wry, and miserable, but when they ask you you can say that there is already a solution, it remains to integrate it. And rewrite it as it should. You can try to express this maxim - if you know how to do the task - do it well. If you do not know - first solve it somehow.
And there is an important point. I would like you to understand. This is not a call to write bad code. The code must be good. This is a call to First Thing First - first the code works, then it refactors.
Now let's talk about Shit Happens. So, we have the code, it even works. Rather, "works." Let's look at a simple example:
using (WebClient xx = new WebClient())
This is a great example of "working" code. Why? Because it does not take into account that sooner or later, our endpoint will fall off. This example does not take into account the so-called edge case - borderline, "bad cases." When you start writing code, think about what can go wrong. In fact, I’m talking not only about remote calls, but about all the resources that are outside of your control — user input, files, network connections, even a database. All that can break, break at the most inopportune moment and the only thing you can do about it is to be ready for it as much as possible.
Unfortunately, not all problems are so obvious. There are a number of problem areas that are almost guaranteed to generate bugs. For example, working with a locale, with time zones. It is pain and shouts "everything works on my machine". They just need to know and work with them carefully.
By the way about user input. There is a very good principle that says that any user input is considered incorrect until proven otherwise. In other words, always validate what the user entered. And yes, on the server too.
- First make the code work,
- Then make it good
- Don't forget about edge cases and error handling.
Now let's talk about code support.
Support is a complicated concept, but I would include three components here - the code should be easy to read, easy to change and be uniform.
Who writes comments in Russian? Nobody writes? Wonderful. In general, one of the problems is non-English code. Do not do it this way. I had a piece of code with classes in Norwegian, and I just could not pronounce their names. It was sad. Obviously, maintaining such a code (for non-Norwegians) will not be a trivial task. But this is rare.
In general, ease of reading is about naming and structure. Names of entities - classes, methods, variables, should be simple, readable and carry meaning. Take our previous example.
using (WebClient xx = new WebClient())
Can you understand what the Do method does, regardless of the implementation? Hardly. Similarly with variable names. In order to understand what kind of xx object you need to look for its declaration. It takes our time, interferes with the understanding of what, in general, occurs in the code. Therefore, names must reflect the essence of the action or value. For example, if you rename the Do method to GetUserName, the code will become a little clearer and in some cases we will not have to look into its implementation. Similarly, with the names of variables in the form of x and xx. True, there are generally accepted exceptions in the form of e for errors, i, k for cycle counters, n for dimensions, and a few more.
Again, for example, take your code that you wrote a month ago and try to read it fluently. Do you understand what is happening there? If yes - I congratulate you. If not, then you have a problem with the readability of the code.
In general, there is such an interesting quote:
"There are only two hard things in Computer Science: cache of invalidation and naming things." © Phil Karlton
There are only two complex things in Computer Science: invalidation of cache and naming.
Remember it when you give names to your entities.
Have pity on your colleagues and yourself. After a week, you have to literally wade through this code to fix or add something in it. Avoiding this is not so difficult:
- Write short functions,
- Avoid a lot of branching or nesting,
- Highlight the logical blocks of code in separate functions, even if you do not intend to reuse them,
- Use polymorphism instead of if
Now let's talk about another difficult thing - that good code is easy to change. Who is familiar with the term Big ball of mud? If someone is not familiar - look at the picture.
Each module depends on each module and a contract change in one place is likely to lead to the onset of a polar chanterelle or at least to a very long debug. Theoretically, it is quite simple to deal with this - reduce the dependence of your code on your own code. The less your code knows about any implementation details, the better it will be for it. But in practice it is much more complicated, and leads to code overload.
In the form of advice, I would formulate it like this:
- Hide your code as deep as possible. Imagine that tomorrow you will have to manually remove it from the project. How many places will you have to fix and how difficult will it be to do this? Try to minimize this amount.
- Avoid circular dependencies. Split the code into layers (logic, interface, data access) and make sure that the layers of the "lower" level are not dependent on the layers of the "upper" level. For example, data access should not depend on the user interface.
- Group the functionality into modules (projects, folders) and hide the classes inside them leaving only the facade and interfaces.
And draw. Just draw on a piece of paper how your data is processed by the application and what classes are used for this. This will help you understand the over complicated places before it all becomes irreparable.
And finally, about uniformity. Always try to adhere to the same style adopted in the team, even if it seems wrong to you. This also applies to formatting, and approaches to solving the problem. Do not use ~~ for rounding, even if it is faster. The team will not appreciate it. And when starting to write new code, always look into the project, maybe something from the one you need is already implemented and it can be reused.
- Correct naming
- Good structure
The code must be sufficiently productive.
Let's just talk a little bit. The following requirement that we consider - the code must be sufficiently productive.
What do I mean by "enough"? Probably, everyone has heard that premature optimizations are evil, they kill readability and complicate the code. It's true. But it is also true that you should know your tool and not write on it so that the web client download Core I7 by 60%. You should know the typical problems that lead to performance problems and avoid them even at the stage of writing code.
Let's go back to our example:
using (WebClient http = new WebClient())
This code has one problem - the synchronous execution of network boot. This is an I / O operation that will freeze our flow until it is executed. In desktop applications, this will lead to a hung interface, and in server applications, to useless memory reservations and exhausting the number of requests to the server. Just knowing such problems, you can already write more optimized code. And in most cases this will be enough.
But sometimes, no. Therefore, before writing code, you need to know in advance what requirements in terms of performance are put to it.
Now let's talk for the tests.
This is no less a holivar topic than the previous one, and maybe even more. C tests all difficult. Let's start with the statement - I believe that the code should be covered by a reasonable number of tests.
Why do we need Code Coverage and tests at all? In an ideal world - not needed. In an ideal world, code is written without bugs, and the requirements never change. But we live in a far from ideal world, so we need tests in order to be sure that the code works correctly (there are no bugs) and that the code works correctly after something has been changed. This is the benefit that tests bring to us. On the other hand, even 100% (due to the specificity of the calculation of metrics) covered with tests does not guarantee that you have covered absolutely everything. Moreover, each additional test still slows down the development, since after changing the functional you will have to update the tests as well. Therefore, the number of tests should be reasonable and the main difficulty lies in finding a compromise between the amount of code and the stability of the system. Finding this facet is quite difficult and there is no universal recipe for how to do it. But there are some tips that you might help to do this.
- Cover the business logic of the application. Business logic is all that the application is created for, and it should be as stable as possible.
- Cover complex, calculated things. Calculations, transformations, complex merdzhi data. Where easy to make a mistake.
- Cover bugs. A bug is a flag that tells us that the code was vulnerable. And this is a good place to write a test here.
- Cover frequently reused code. There is a high probability that it will be updated frequently and we need to be sure that adding something one will not break the other.
Do not cover without much need
- Other libraries - look for libraries whose code is already covered with tests.
- Infrastructure - DI, automapping (if there is no complex mapping), and so on. For this, there are e2e or integration tests.
- Trivial things are assigning data to fields, forwarding calls, and so on. Almost certainly you will find much more useful places to cover them with tests.
Well, in general, that's all.
Let's sum up. Good code is -
- Working code
- Which is easy to read,
- Easy to change
- Fast enough
- And covered with tests in the right quantity.
Good luck in this difficult path. And most likely, your gonna hate this. Well, if not, welcome!