Code Aesthetic

发布时间 2023-11-05 19:54:55作者: SRIGT

01 Abstraction

Abstraction is the process of aggregating code with high similarity among multiple classes into one class to achieve common goals.

The idea of "identifying repetition and extracting it out" will make us get into the mode of "code repetition bad and more abstraction good". But there's hidden trade-off that doesn't get considered, it is coupling.

Coupling is an equal and opposite reaction of abstraction. For every bit of abstraction you add, you have added more coupling.

There are two cases where it can be worth to use abstraction:

  • Many implementations with worth complex construction
  • Deferred execution from creation

It's good to only apply abstraction when the value it brings outweighs the coupling.

02 Naming things

There are only two hard things in computer science: cache invalidation and naming things.

(1) You shouldn't name variables with single letter

Because the single letter doesn't tell you anything about the variable.

int x;

It might because math and computer science were more or less the same.

The right way like this:

int number;

(2) Don't abbreviate names

int bw;

Abbreviations rely on context that you may or may not have. Abbreviations can make you spend more time reading code then writing code.

int boardWidth;

Abbreviations used to help because of two reasons:

  • saved you typing
    • Auto-Complete takes less keyboard strokes than ever to write variable names.
  • screens were 80 characters wide
    • We have massive 4K screen now.

So there is no real advantage to abbreviation.

(3) Don't put types in your name

This typing method named Hungarian notation. Hungarian notation is where you'd prefix the type to the variable name.

bool bIsValid;
int32_t iMark;
uint32_t uIndex;
char* szName;

This might because everything would basically be int before we had good standard types in C. The type of the variable wouldn't actually tell you what was inside of it.

int bIsValid;
int iMark;
int uIndex;
int szName;

But now with statically typed languages, the type should tell you exactly what you are looking at. So putting types in your variables is no longer necessary.

bool IsValid;
int32_t Mark;
uint32_t Index;
char* Name;

(4) Add units to variables unless the type tells you

It is good to put units in your variables names.

If you have a function that accepts a delay time, for example, and if the value of the formal parameter is in seconds, you should name the variable like this.

void execute(int delaySeconds) {}

But even better than that is to have a type that removes the ambiguity completely. In C#, there is a type called TimeSpan. And in C++, it can be chrono::duration.

void execute(TimeSpan delay) {}

The type abstracts the user from understanding the exact underlying unit.

For dynamically typed languages like Python, you cannot rely on type declarations to help.

def __init__(self, delaySeconds):
    self.delay = delaySeconds

(5) Don't put types to your types

In C# there's this pattern of prefixing interfaces with "I".

interface IExecute {}

Don't naming a class with "Base" or "Abstract".

class Student extend BaseStudent {}

This isn't a great name because it doesn't help the users of the class.

If you ever find yourself unable to come up with a good name for the parent class, it probably means that we should actually rename the child class instead.

class NewStudent extend Student {}

Sometimes if you are struggling to name something, it's actually indicative that the code structure is to blame.

(6) Refactor if you find yourself naming code "Utils"

You don's see a bundle of utils in standard libraries. Because they can all be sorted into modules that have good names.

03 Never Nester

The Never Nester never nests their code.

Nesting code is when you add more inner block to a function. We consider each open brace to be adding one more depth to the function.

Here is a function which its depth get 4:

int calculate(int left, int right) {
    if (left < right) {
        int sum = 0;
        for (int number = left; number <= right; number++) {
            if (number % 2 == 0) {
                sum += number;
            }
        }
        return sum;
    } else {
        return 0;
    }
}

There are two methods you can use to de-nest:

  1. Extraction: this is where you pull out part of the function into its own function.
  2. Inversion: this is simply flipping conditions and switching to an early return.

With these methods, we can change that function like this:

int filterNumber(int number) {
    if (number % 2 == 0) {
        return number;
    }
    return 0;
}
int calculate(int left, int right) {
    if (left > right) {
        return 0;
    }
    int sum = 0;
    for (int number = left; number <= right; number++) {
        sum += filterNumber(number);
    }
    return sum;
}

04 Prefer Composition Over Inheritance

Both composition and inheritance are trying to solve the same problem: You have a piece of code that you are trying to reuse.

class Person {
    public string name { get; set; }
    public int age { get; set; }
    public bool isValid(string name, int age) {
        return (this.name == name && this.age == age);
    }
}

(1) Inheritance

Inheritance is when you have a class that contains functionality you want to reuse. So you create a subclass to extending its functionality.

class Student extend Person {}

If you simply extend a class, you have basically created a copy of the class with a new name, and then you can inject new methods to extend or override parts.

class Student extend Person {
    public string school { get; set; }
    public int grade { get; set; }
    public bool isValid(string school, int grade) {
        return (this.school == school && this.grade == grade);
    }
}

But when you need to create multiple different subclasses, you will discover the downsides of inheritance that you've couple yourself to the parent class. The struct of parent is thrust upon the child. We are forced to implement certain functions that we don't need, in order to be able to reuse the functions that we do need, even thought they don't make sense for our subclass.

Inheritance breaks down when you need to change the code. Change is the enemy of perfect design and you often paint yourself into a corner early on with your inheritance design. This is because inheritance naturally asks you to bundle all common elements into a parent class. But as soon as you find an exception to the commonality, it requires big changes.

So our alternative is to use composition.

(2) Composition

Composition is a pattern that you are doing whenever you reuse the code without inheritance. If we have two classes and they want to reuse code, they simply use the code. With the help of composition, we do not need to use abstract class and no longer inherit the parent class. For methods that need to be overrode, we'll simply pass in the parent class in question instead of accessing them through this.

class Student {
    public void execute(User user) {}
}

Now the user no longer chooses the one class that suits their needs. They also combine classes together for their particular use case.

(3) Abstracting with Inheritance

Inheritance is interesting because it actually combines two capabilities: the ability to reuse code and the ability to build an abstraction. Creating abstractions allow a piece of code to reuse another piece of code, but also not know which piece of code it is using.

But with composition, you don't have parent classes. You have just using the types you want. For these new classes without inheritance, we still want to be able to call the methods in "parent class" without caring about which class it is. It is time for interfaces to come in.

(4) Abstracting with Interfaces

Instead of a full parent class with all its variables and methods, an interface simply describes the contract of what an object can do.

interface StudentInterface {
    void execute(User user);
}

Interfaces are minimal. Parent classes share everything by default, making them more difficult to change. But interfaces define only the critical parts of the contract and are easily tacked on to existing classes. There is a name for what we just did there: Dependency Injection (Passing in an interface for what you are going to use).

(5) When to use Inheritance

The cons of composition:

  • Code Repetition in interface implementations to initialize internal types
  • Wrapper Methods to expose information from internal types

The pros of composition:

  • Reduces coupling to re-used code
  • Adaptable as new requirements come in

Inheritance might be useful if you are working inside of an existing system that had highly repetitive code where you only needed to modify one thing. If you do use the Inheritance design the class to be inherited, you should avoid protected member variables with direct access. For overriding, create a protected API (Application Programming Interface) from child classes to use. And mark all other methods as final/sealed/private.

05 No Comments

Here is a piece of code.

if status == 2:
    message.markSent()

That means the method of message which is called markSent will execute when the value of status is 2. Looking at that code, it is not obvious what 2 signals. We could add comment to explain. But even better we can create a constant representing the variable instead.

MESSAGE_SENT = 2
if status == MESSAGE_SENT:
    message.markSent()

The if statement now reads like a comment.

If your code is complex enough that it warrants a comment, you should see if you can simplify or refactor the code to make it better instead.

(1) Types can also make comments redundant

In older C++, there's no built in memory management. You often have a function like this where you get back a thing.

message* receive_message();

But we need to make clear who will take ownership (the responsibility to release the memory after everyone is done with it) of the thing. That is rely on comments to explain the ownership. But since C++11, there has been added a new type called unique_ptr.

std::unique_ptr<message> receive_message();

The type represents a unique reference to the object and tells you explicitly that you now own it without the need for a comment.

But why not both write high-quality code and add comments?

(2) Comments get bugs like code

// 2 means message sent
if status == 2:
    message.markSent()

When people make changes to code, they often do not update the comment to match.

// 2 means message sent
if status == 2:
    message.markReceived()

But unlike comment, we have tools to stop bugs from entering code: tests, compiler checks and linting. Comments can lie, but code cannot.

For understanding the code, the better way than reading comments is reading Code Documentation.

(3) Code Documentation

Code Documentation describes the high level architecture and public APIs of a system. The difference between Code Documentation and Code Comments:

  • Code Documentation: How code is used
  • Code Comments: How code works

Tools like Doxygen, pydoc and JavaDoc generate documents directly from code files and therefore change alongside our code. In useful Code Documentation, you need to include the following key points:

  • What a class or API represents
  • Interface expectations
    • Thread safety
    • Possible states
    • Error conditions

(4) Exceptions for Comments

  • Non Obvious Performance Optimizations
  • References to Math or Algorithms

06 Premature Optimization

There is a relationship:

graph LR Performance-->Velocity-->Adaptability-->Performance

The key with the triangle is that performance is a problem, but it's not usually the first problem. There are two camps in performance issues:

  1. Macro Performance

    There are system wide performance considerations.

  2. Micro Performance

    This is fine tuned performance.

Premature Optimization usually occurs for micro performance.

For example, in C++, ++i is faster than i++, but we rarely use ++i. Because it cannot bring significant performance improvements.

Before beginning the optimization, you should check if it has a real performance problem.

There're so many factors to performance that there's only one way to properly optimize:

  1. Measure

    Measuring is critical because it can show you what will make things faster can make things slower.

  2. Try Something

    You can help form a hypothesis of how to make things better by doing an analysis.

    1. Data Structures

    2. Profile

      A profiler can tell you what are the hotspots of your code. It can point out functions that are the most expensive.

    3. Think About Under The Hood

    4. Think About Memory

  3. Measure Again