Clean coding practices

Utsav Ghimire
9 min readMay 3, 2023

--

https://www.freecodecamp.org/news/clean-coding-for-beginners/

Clean coding is writing code that is easy to read, understand, test, and maintain.

The goal of clean coding is to create code that is not only functional but also well-structured, efficient, and easily understandable for both the coder and other developers who may need to work with the code in the future.

Some of the principles for clean coding are:

  1. Use a consistent and meaningful naming convention.
  2. Use well-defined functions.
  3. Write modular code.
  4. Avoid nesting callbacks.
  5. Handle errors gracefully.
  6. Write tests.
  7. Avoid magic and surprises.

Let us discuss each point one by one.

1. Use consistent naming convention:

It is easy to say that names reveal intent. Choosing good names takes time but saves more than it takes. So take care of your names and change them when you find better ones. Use meaningful and descriptive names for the variables, functions, and classes, and follow a consistent naming convention.

The name of a variable, function, or class tells you why it exists, what it does, and how it is used. If a name requires a comment, it does not reveal its intent.

Example:


// bad
// function name does not define its intent
List<int> getData(List<Map<String,dynamic>> data){
List<int> a=[];
for(int i=0; i<data.length;i++){
if(data[i]['flag']== 5){
a.add(data[i]['data']);
}
}
return a;
}

// Good

// Use constants for constant values
const flaggedValue=5;

// define meaningful names for functions and arguments
List<int> getFlaggedItems(List<Map<String,dynamic>> responseItems){
List<int> flaggedDataList=[];

for(int index=0; index<responseItems.length;index++){
if(responseItems[index]['flag']== responseItems){
flaggedDataList.add(responseItems[index]['data']);
}
}

return flaggedDataList;
}

Additionally, avoid using names that vary in small ways.

// how long does it take to spot the subtle difference between
const List<String> jsonResponseNameList=[];
const List<String> jsonResponseBioList=[];

// use names that can be properly distinct
const List<String> nameListFromResponse=[];
const List<String> bioListFromResponse=[];

2. Use well-defined functions:

A function is simply a “chunk” of code you can repeatedly use rather than writing it out multiple times.

So, to use it repeatedly, a function should be well-defined. A function contains a return type, function name, function body, and arguments. To make the function reusable and well-defined, all the parts of the functions should be meaningful.

A good function has one entry and one exit. There should only be one return statement in a function, no break or continue statements in a loop, and never any goto statements.

Functions should do one thing. They should do it well. They should do it only. The name of the function should convey what it is doing.

The ideal number of arguments for a function is zero (niladic). Next comes one (monadic), followed closely by two (dyadic). More than two arguments for the function should be avoided where possible.

// bad function
// uses many arguments, confusing names, hard to distinct arguments
shift(double x0, double x1, double y0, double y1, double angle);

// the number of arguments is justifiable
// distinct and meaningful names for arguments
shiftLineByAngle(Position position, double angle);

A function should hold one level of abstraction. So, functions that do one thing cannot be reasonably divided into sections. If you have a function divided into sections like declarations, initialization, computation, etc., it’s an obvious symptom that the function is doing more than one thing.

The function should never change the outer state. So, it is best practice to return values rather than change the state.

// bad practice
shiftLineByAngle(Position position, double angle){
// compute the value
// assign the value to the finalPosition which is out of scope
finalPosition = computedValue;
}

// good practice

Position shiftLineByAngle(Position position, double angle){
// compute the value
return computedValue;
}
// so the value of the final position of the line is returned by the function
// finalPosition = shiftLineByAngle(currentPosition, inclination);

Passing a boolean into a function is a truly terrible practice. It immediately complicates the method's signature, loudly proclaiming that this function does more than one thing.

3. Write modular code:

Modular code is organized in a hierarchical structure, where larger modules may consist of several smaller modules, and so on. Each module should be responsible for a specific task. Also, the modules should have a clear interface that defines how it interacts with other modules, making it easier to test and debug.

When code is organized into smaller, self-contained functions or classes, it is easier to isolate and test individual pieces of code. This can make it easier to identify the source of any errors that may arise.

Some of the advantages of writing modular code are:

i. Easier to understand: Modular code is easier to understand because each module has a well-defined purpose, which makes it easier to follow the logic of the program.

ii. Easier to maintain and test: Modular code is easier to maintain and test because each module can be updated, replaced, or tested independently of the rest of the code. This makes it easier to identify and fix errors and reduces the risk of introducing new errors when making changes to the code.

iii. Easier to collaborate: Modular code is easier because different developers can work on other modules simultaneously. This reduces the risk of conflicts and makes merging changes into the main codebase easier.

4. Avoid nesting callbacks:

In programming, nesting callbacks occur when you pass a function as an argument to another function and then invoke that function from within the first function. This pattern is commonly used in asynchronous programming to handle events and callbacks.

Example:

 void performActions() {
fetchData().then((data) {
processData(data).then((processedData) {
displayData(processedData);
});
});
}

In this example, a getData function takes a callback function as an argument. When the data is retrieved, the getData function invokes the callback function and passes the data as an argument. The processData function also takes a callback function as an argument and is invoked when the data has been processed. Finally, the displayData function is called to display the processed data.

The problem with nesting callbacks is that they can lead to complex and unreadable code. As the number of nested callbacks increases, the code can become difficult to follow, debug, and maintain. Additionally, error handling becomes more complicated when using nested callbacks, making it harder to catch and handle errors.

We can use modern asynchronous programming techniques such as async/await and Futures. These techniques allow us to write more readable and sequential code while avoiding nested callbacks.

Future<void> performActions() async {
final data = await fetchData();
final processedData = await processData(data);
displayData(processedData);
}

5. Handle errors gracefully:

Error handling is a crucial aspect of software development, as it helps ensure that your program can gracefully handle unexpected or erroneous situations.

Some of the error handling patterns are:
i. Return Values: One of the most common errors handling patterns is to use return values to indicate errors. For example, a function could return an error code or null pointer when an error occurs.

double divide(int x, int y) {
if (y == 0) {
return -1;
}
return x / y;
}

// usage
double result = divide(10, 0);
if (result == -1) {
print("Error: division by zero\n");
} else {
print("Result: $result");
}

ii. Exception Handling: Another popular error handling pattern is exception handling. In this approach, errors are raised as exceptions, which are then caught and handled by the calling code. This can help make error-handling code more concise and easier to read.

double divide(int x, int y) {
if (y == 0) {
throw Exception('Divided by zero');
}
return x / y;
}

// Usage

void main() {
try {
double result = divide(10, 0);
print("Result: $result");
} catch (e) {
print("Error: $e");
}
}

iii. Assertion Checking: Assertion checking is a pattern that involves placing assertions in the code to check for specific conditions. If the assertion fails, an error is raised. This can be particularly useful during development and testing, as it can help catch errors early in the development cycle.

double divide(int x, int y) {
print(x);
assert(y != 0, "Cannot divide by zero");
return x / y;
}

void main() {
try {
double result = divide(10, 0);
print("Result: $result");
} on AssertionError catch (e) {
print("AssertionError: $e");
}
}

iv. Retry Logic: Sometimes errors occur due to temporary conditions, such as network timeouts or database failures. Retrying the operation may be a viable error-handling pattern. For example, if the first attempt fails, a network request could be retried after a short delay.

int performActions() {
for (int i = 0; i < 3; i++) {
try {
// perform actions
// return the result
} catch (e) {

Future.delayed(Duration(seconds: 1));
}
}
print("Error: could not complete operation after 3 attempts\n");
return -1;
}

void main() {
print("Result: ${performActions()}");
}

v. Circuit Breaker: A circuit breaker pattern is a technique for handling errors in distributed systems. When a system component fails repeatedly, the circuit breaker “trips” and redirects traffic to a backup component. This can help prevent cascading failures and improve the system's overall resiliency.

import 'dart:math';

enum CircuitBreakerState { closed, open, halfOpen }

class CircuitBreaker {
CircuitBreaker({
required this.maxFailures,
required this.resetTimeout,
}) : assert(maxFailures > 0);

final int maxFailures;
final Duration resetTimeout;

CircuitBreakerState _state = CircuitBreakerState.closed;
int _failureCount = 0;
DateTime? _lastFailureTime;

Future<T> run<T>(Future<T> Function() function) async {
switch (_state) {
case CircuitBreakerState.closed:
try {
final result = await function();
_failureCount = 0;
return result;
} catch (e) {
_failureCount++;
if (_failureCount >= maxFailures) {
_state = CircuitBreakerState.open;
_lastFailureTime = DateTime.now();
}
rethrow;
}
case CircuitBreakerState.open:
final timeSinceLastFailure =
DateTime.now().difference(_lastFailureTime!);
if (timeSinceLastFailure > resetTimeout) {
_state = CircuitBreakerState.halfOpen;
return run(function);
} else {
throw CircuitBreakerOpenException();
}
case CircuitBreakerState.halfOpen:
try {
final result = await function();
_failureCount = 0;
_state = CircuitBreakerState.closed;
return result;
} catch (e) {
_failureCount++;
_state = CircuitBreakerState.open;
_lastFailureTime = DateTime.now();
throw CircuitBreakerOpenException();
}
}
}
}

class CircuitBreakerOpenException implements Exception {}

// Usage example
final circuitBreaker = CircuitBreaker(
maxFailures: 3,
resetTimeout: Duration(seconds: 10),
);

// Usage example
void main() async {
final circuitBreaker = CircuitBreaker(
maxFailures: 3,
resetTimeout: Duration(seconds: 10),
);
try {
await circuitBreaker.run(() async {
final randomNumber = Random().nextInt(100);
if (randomNumber < 50) {
throw Exception('Something went wrong');
}
return 'Success!';
});
} on CircuitBreakerOpenException catch (e) {
// handle open circuit breaker state
print(e);
} catch (e) {
// handle other exceptions
print(e);
}
}

6. Write tests:

Testing is a critical part of software development, and writing tests is essential to ensure your code's quality, correctness, and reliability. Writing tests for your program can help you catch bugs early, prevent regressions, and ensure your program behaves as expected.

Here are some tips to keep in mind when writing tests:

i. Write tests early: It’s good practice to write tests as you write your code. This will help you catch bugs early and make testing individual code pieces easier. It’s also easier to modify your code and ensure that the tests continue to pass.

ii. Test individual components: Test each app component in isolation to ensure they work as expected. This can help you catch bugs early and simplify troubleshooting issues.

iii. Use mocking and dependency injection: Use mocking and dependency injection to isolate components and make testing easier. This can help you test individual pieces of code in isolation without having to set up complex environments.

iv. Automate your tests: Automating your tests can help you catch bugs early and ensure that your app works as expected in different scenarios. Use continuous integration and continuous testing(CI/CD) tools to automate your tests and make testing a seamless part of your development process.

In summary, writing tests is a critical part of software development, and it’s essential to ensure the quality and reliability of your code. Following these tips, you can write practical tests for your software and ensure it works as expected in all scenarios.

7. Avoid magic and surprises:

In software development, “magic” refers to code that performs a function not immediately apparent from reading the code. This can make code difficult to understand and maintain, leading to surprises when unexpected behavior occurs. To avoid magic and surprises in your code, here are some tips:

i. Avoid implicit behavior: Avoid implicit behavior that is not immediately apparent from the code. For example, avoid using operators or language features that perform unexpected behavior, such as operator overloading. And avoid using shortcuts or language features that may be unclear to understand to others.

ii. Document assumptions and constraints: Document any assumptions or constraints that your code relies on, especially if they are not immediately apparent from the code. This can help others understand the behavior of your code and avoid surprises.

iii. Avoid global state: Avoid using global state in your code, which can lead to unexpected behavior and side effects. Instead, use dependency injection and other patterns to manage the state.

iv. Use consistent coding conventions: Use consistent coding conventions throughout your codebase, as this can help make the purpose and behavior of your code more apparent.

In conclusion, clean coding practices are essential for developing high-quality, easy-to-read, maintain, and test software. By following best practices such as using meaningful names, keeping functions and classes small and focused, using comments and documentation effectively, handling errors gracefully, using version control, and writing tests, developers can ensure that their code is easy to understand and modify.

Other essential practices include avoiding unnecessary complexity, using design patterns and SOLID principles, avoiding code duplication, and dependency injection, and avoiding magic and surprises. By adopting these practices, developers can create more maintainable, flexible, and resilient code, leading to faster development cycles, fewer bugs, and more satisfied users.

Ultimately, clean coding practices are about writing effective and efficient code while being easy to understand and modify.

--

--