Clean Code: A practical approach

Clean Code

How to generate and maintain good code?

By Andrea Alonso, Software Engineer at etermax

Most of us programmers have found ourselves in a situation where we have to work with a messy code, making it difficult to understand the function of the various lines we are reading. On occasions, we ask ourselves why certain changes are being made to variables or calls, but we are scared to intervene out of fear of breaking the code used in the production environment.

Production code: the code that runs on the production versions of our servers or products, i.e., the one that is being used by the real audience of our product or service in a real environment.

Also, we must admit that, in some cases, we were the creators of what troubles us nowadays, and we would rather code again than add new functionalities.

Those situations should make us reach the conclusion that fixing the code later on might not be a great idea, and that we could make small changes or put practices in use instead. This will help us build a more understandable code, and it will be easier to work on in the future.

At etermax we always aim at generating clean code, in other words, one that’s easy to understand, change, and extend if needed. This way, if we have to continue a feature made by our peers, we will be able to understand how it works easily, and we will be certain that our modifications will not affect other parts of the system without us realizing it. On many occasions, it won’t be necessary to check with our peers how the feature was built, as the code itself will explain to us, line by line, what’s going on.

Let’s start with a not-so-clean example

When building methods, we like to keep them short and sweet, oriented towards following one goal.

Let’s take the following function as an example

private void ReceiveDamage(float damage)
{
    _life -= damage;
    if (_life <= 0)
    {
        _view.ShowDeath();
        //Add permanent injuries
        
        //Reduce experience by half on death
        _experiencePoints /= 2;
        //Lose 5 coins
        _accumulatedCoins = Math.Max(0, _accumulatedCoins - 15);
    }
    else
    {
        _view.ShowReceiveDamageFeedback();
        
        //Enter injured condition if life is low
        if (_life <= _maxLife * 0.3f)
        {
            //injured modifiers
            _movementSpeed = 0.5f;
            _attackSpeed = _weapon.GetAttackSpeed() * 0.6f;
            _view.ShowCriticalCondition();
        }
    }
}

We can’t understand what’s going on inside of it at a glance. We have to stop on each of its lines, mostly if there are variables whose names are not so intuitive, to distinguish the purpose of the method.

Even if there are some helpful comments, not all the lines are commented on in this example, and some comments like “Add permanent injuries” and “Lose 5 coins” haven’t been updated with the latest changes. This forces us to distinguish comments that actually explain a fragment from those leading to confusion. For this reason, we could consider cutting down the amount of lines and focusing on making sure each of our methods follow the Single Level of Abstraction principle. In other words, each function should deal with concepts related to just one level of abstraction. We can achieve this by extracting new methods that hide details or implementations irrelevant to the context we are currently analyzing. For example, if we carried out this process for the code mentioned above we could obtain the following:

private void ReceiveDamage(float damage)
{
    LowerLife(damage);
    if (LifeBelow0())
    {
        _view.ShowDeath();
        //Add permanent injuries

        //Reduce experience by half on death
        OnDeath();
    }
    else
    {
        _view.ShowReceiveDamageFeedback();
        
        //Enter injured condition if life is low
        OnDamage();
    }
}

private void OnDamage()
{
    if (LifeBelow30Percent())
    {
        //injured modifiers
        Injured();
        _view.ShowCriticalCondition();
    }
}

Usually, we also aim to reduce code repetition during this method extraction. For example, if a fragment of code to carry out the same calculations or checks, or execute a certain behavior, appears multiple times throughout a codebase, it would be ideal to extract it in one method and replace all its appearances with a call. This way, these methods will be available the next time we need to replicate that behavior or check, speeding up our work and avoiding having to look up said fragment to copy and paste it in a new place. More importantly, we will also prevent errors from arising when changes need to be made to these methods because the needs of the business have changed. For instance, if we didn’t have extracted functions, we would risk modifying only some places where this code could be found, making only some parts of our application behave as expected.

We don’t limit this kind of practice to functions; we also apply them to classes in general. In this case, we follow an important concept: the S.O.L.I.D. principles. We’d like to stress that the ‘S’ stands for Single Responsibility. According to this principle, we should make sure classes have only one responsibility, and that all its methods are related to it. For this reason, if we’re building a class and encounter functions that are not in line with the purpose of said class, we will extract them into new classes. This way, we will avoid having long scripts where finding the fragments we need is difficult, and we will have more certainty when choosing classes in cases where creating a new class doesn’t apply.

Method names and comments

Going back to the previous example, we can see that even if we managed to summarize our functions and separate the different contexts more effectively, we have not achieved a clean and understandable code yet. This brings us to another issue: naming methods. As seen in the example, if our methods don’t have descriptive names, we are still forced to inspect each of them and go through their lines to grasp the functionality of said fragment of code. However, if we take the time to give our functions descriptive names, we can obtain something like:

private void ReceiveDamage(float damage)
{
    ReduceLifeBy(damage);
    if (HasDied())
    {
        _view.ShowDeath();
        ApplyDeathPenalties();
    }
    else
    {
        _view.ShowReceiveDamageFeedback();
        CheckCriticalCondition();
    }
}

private void CheckCriticalCondition()
{
    if (LifeBelowCriticalThreshold())
    {
        ApplyInjuredPenalties();
        _view.ShowCriticalCondition();
    }
}

Implementing these small changes only took a couple minutes, and we achieved a code that’s understandable at a glance. In addition, navigating the definitions of these new methods is now necessary only when we want to know particular implementations or change them.

We also noted that the comments used to explain this code were no longer necessary, so we chose to remove them. We normally think comments are a symptom indicating that the way the code was structured may not be the right one. For example, they may indicate that we can extract methods in a certain place, or that we should ask ourselves how we can simplify that fragment or make it more descriptive.

Following these practices constantly prevents the generation and maintenance of comments that, as seen above, generate confusion regarding what the code actually does when they’re not updated.

Magic numbers

Let’s have a closer look at one of these functions so we can continue assessing possible improvements:

private void ApplyInjuredPenalties()
{
    _movementSpeed = 0.5f;
    _attackSpeed = _weapon.GetAttackSpeed() * 0.6f;
}

It’s difficult to determine what 0.5f and 0.6f mean, why these numbers were added, and whether they are repeated throughout the code or not. For this reason, if we were to change these values we would have to look for each of its occurrences. In these cases, we choose to add a constant with a descriptive name. For this example, it could look something like this:

private const float InjuredSpeed = 0.5f;
private const float InjuredAttackSpeedReduction = 0.6f;

private void ApplyInjuredPenalties()
{
    _movementSpeed = InjuredSpeed;
    _attackSpeed = _weapon.GetAttackSpeed() * InjuredAttackSpeedReduction;
}
Code smells: How to recognize bad code?

In general, there are many indicators of a dirty code, such as:

  • Rigidity: Changing the code is difficult, as every time we want to make a change we have to make changes to other sections of the code, which are not completely related to the original changes in many cases.
  • Fragility: Sections of our code get broken every time we make a change, introducing new bugs and unexpected behaviors.
  • Unnecessary complexity: Many times, there are simpler solutions than those implemented. Occasionally, working with bad code forces us to look for solutions that are not the most appropriate, but avoid breaking other functionalities added in an unideal way.
  • Unnecessary repetition: As already mentioned, the same fragment of code is found in various parts of the project, making it impossible to locate all the repeated fragments in case we need to modify them.
  • Opacity: The code is difficult to understand. When working with a section, we must spend time trying to understand what it does, its dependencies, what we can and can’t modify, among other things.
Maintaining code

We must clarify that trying to write good code is not enough; we have to maintain its quality. This explains the importance of the Boy Scout Rule, which says we should always leave the code better than we found it. This rule motivates us to make small changes — like the ones we mentioned throughout this article — so that working on certain classes is constantly made simpler, not just for us, but for our coworkers who might need to make changes in the future.

Conclusion

As discussed throughout the article, there are many practices we can follow to create a code that’s easy to understand, extend, and maintain. By raising awareness of these practices we can create a team that aims to maintain codes clean, neat and constantly updated. In addition, following these practices ensures everyone feels comfortable and prevents conflicts. This way, we build stronger foundations on which we work daily, fostering teamwork, building features with less errors, and saving time trying to understand existing code we want to modify.

, ,