Why more code is not always a bad thing
There's a prevailing myth in software development that less code is always better. While concise code can benefit specific contexts, it doesn't necessarily equate to better code. Code quality, maintainability, and readability are often far more important than simply being less.
This article will explore why writing more code can often lead to better software, especially in Dart and Flutter applications. We'll focus on clarity, modularity, flexibility, robustness, and error handling. We'll also discuss the drawbacks of "magic" code and the importance of linting and rules in maintaining code quality.
Clarity and Readability
One primary reason for writing more code is to enhance clarity and readability. Code is read more often than written, and ensuring it is easy to understand can save time and reduce errors in the long run. When code is clear and readable, it becomes easier for other developers (or even yourself later on) to understand what it is doing. This can prevent bugs, simplify maintenance, and facilitate collaboration among team members.
In addition, developers have different backgrounds and experiences (even on the same team), and simpler code is usually easy for everyone to change.
Look at the following code:
var x = a ?? b ?? c ?? d ?? e;
This line of code is short and uses the null-coalescing operator to assign x
the first non-null value among a
, b
, c
, d
, and e
. While efficient, it's not immediately clear to someone reading the code for the first time what this line is doing.
Now look at a similar logic:
T? firstNonNull<T>(List<T> values) {
for (var value in values) {
if (value != null) {
return value;
}
}
return null;
}
var result = firstNonNull([a, b, c, d, e]);
Here, we have expanded the logic into multiple lines. Although it's longer, each step is explicit, making it easier to understand what the code is doing. This reduces cognitive load and makes it easier to debug or modify in the future.
Let’s explore another example,
double calc(double p, double r, int n) {
return p * pow((1 + r / 100), n);
}
As you can see, this function comes with some names that are pretty hard to understand what they are for, whereas we can write full names as follows:
double calculateCompoundInterest(
double principal,
double annualRate,
int years,
) {
return principal * pow((1 + annualRate / 100), years);
}
Descriptive variable and function names make the code self-documenting, improving readability without additional comments. When someone reads calculateCompoundInterest
, they immediately understand the purpose of the function, unlike the ambiguous calc
.
Let’s review another example, the Local function. Local functions complicate the readability of the containing function by inverting its flow and can lead to unexpected inclusion of outer scope variables. In fact, this is a perfect scenario in which a lint tool like DCM can help you prevent writing such a code.
Consider the following example:
bool someFunction(String externalValue) {
final value = 'some value';
// LINT
bool isValid() {
// some
// additional
// logic
}
return isValid() && externalValue != value;
}
In this example, DCM can warn you at isValid
function. By getting this warning, you can refactor your code to become similar to the following:
bool someFunction(String externalValue) {
final value = 'some value';
return isValid() && externalValue != value;
}
bool isValid() {
// some
// additional
// logic
}
This is only one of the examples among many rules that DCM can help you write more readable code. This rule is avoid-local-functions
and you can read more about it in our documentation.
I would encourage you to take a moment and look at some of DCM Style Rules where many of them can help you write cleaner code simply by putting your codebase on autopilot handed to the DCM linting tool.
Modularity and Reusability
Breaking down code into smaller, reusable modules can result in more lines of code but significantly improve a project's structure and maintainability. Modularity promotes the single responsibility principle, where each module or function has one clear responsibility. This makes code easier to test, debug, and extend. It also facilitates reusability, allowing you to use the same code in different parts of the application or even in other projects.
Let’s take a simple example in Flutter:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('My App')),
body: Column(
children: [
Text('Welcome to my app!'),
ElevatedButton(onPressed: _doSomething, child: Text('Press me')),
],
),
);
}
In this example, the build
method does everything at once. This method could become unwieldy and complex to maintain as the application grows.
You can now break the widgets into smaller pieces, which is a good practice in Flutter, too:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('My App')),
body: Column(
children: [
WelcomeText(),
ActionButton(onPressed: _doSomething),
],
),
);
}
class WelcomeText extends StatelessWidget {
Widget build(BuildContext context) {
return Text('Welcome to my app!');
}
}
class ActionButton extends StatelessWidget {
final VoidCallback onPressed;
ActionButton({required this.onPressed});
Widget build(BuildContext context) {
return ElevatedButton(onPressed: onPressed, child: Text('Press me'));
}
}
Breaking the code into smaller components enhances reusability and makes it easier to manage and test each application part independently. Each component (WelcomeText
and ActionButton
) can be developed, tested, and debugged separately, improving maintainability.
Indeed, this example was a simple one, but it was important to convey an important message: try to ensure you break down into smaller widgets, especially if they are repeating the same thing. In this case, you also follow one of the software engineering principles: DRY, do not repeat yourself.
When we talk about modularity, it’s time to touch on CBO. Coupling Between Object Classes (CBO) refers to the number of classes to which a particular class is coupled.
In Flutter, this means a class is considered coupled to another class if methods or instance variables from one class use methods or instance variables from another class. This coupling can go both ways, but each relationship is counted only once.
For example, imagine you have a User
class that interacts with methods from AuthService
, DatabaseService
, and NotificationService
. This User
class is coupled with these three services. If the User
class also accesses methods or variables from a ProfileService
class, the CBO increases.
High CBO is undesirable as it indicates excessive coupling, which hampers modular design and reuse. Independent classes are easier to reuse and maintain, and higher coupling increases sensitivity to changes, making maintenance more difficult. It can also indicate fault-proneness.
Therefore, a recommended CBO is 12 or less to maintain modularity and encapsulation. To achieve this in Flutter, minimize direct interactions between classes, use design patterns like dependency injection, and promote the use of interfaces and abstract classes.
For instance, in your configuration, you can set the CBO limit to 12 to ensure better software quality:
dart_code_metrics:
...
metrics:
...
coupling-between-object-classes: 12
...
DCM comes with many other metrics that can help you analyze your modularity and enhance your codebase especially if you are in a team and working on a big project. The good part of it is that metrics are available for free. Read more about CBO on DCM documentation.
Let’s take a look at another aspect.
Flexibility and Extensibility
Writing more code to build a flexible and extensible architecture can ease future extensions and modifications, which is crucial in large-scale projects. A flexible and extensible codebase allows for easy addition of new features and adaptation to changing requirements without extensive refactoring. This is particularly important in agile development environments where requirements can evolve rapidly.
Let's take a simple Car
class as an example:
class Car {
String model;
String color;
Car(this.model, this.color);
}
This initial version of the Car
class is simple and to the point. However, what happens when we need to add more attributes?
class Car {
String model;
String color;
double engineSize;
Car(this.model, this.color, this.engineSize);
}
Instead of initially writing a minimalistic version of the Car
class, anticipating future needs and designing a more extensible structure can save significant refactoring effort. Adding a new attribute like engineSize
becomes straightforward because the class is designed to be flexible.
Flutter's flexibility in creating custom designs can lead to situations where standard widgets are rarely used, and custom components are preferred to maintain a unique design system.
For instance, you might have custom buttons that follow your design guidelines, but the standard TextButton
is still available and might be mistakenly used by the team.
That’s where a linting tool like DCM comes in handy.
To ensure consistency and guide your team on the correct widgets to use, you can configure banned-usage
in your dart_code_metrics
. This helps in maintaining both flexibility and consistency in your Flutter codebase.
Here is an example configuration for banned-usage
:
dart_code_metrics:
...
rules:
...
- `banned-usage`:
entries:
- ident: TextButton
description: "Please use SpecialButton in this package."
This configuration will highlight all usages of TextButton
and provide guidance on what should be used instead, ensuring that your design system is consistently followed.
By combining the principles of flexibility and extensibility with tools like banned-usage
, you create a Flutter codebase that adapts to future changes while maintaining a consistent design system.
For more details on how to configure banned-usage
, refer to the documentation.
Robustness and Error Handling
Robust error handling can result in more lines of code but significantly enhances the application's reliability and stability. Thorough error handling ensures the application can gracefully handle unexpected situations, preventing crashes and providing a better user experience. It also aids in debugging and maintenance by clarifying where and why errors occur.
Take a network call as an example:
Future<void> fetchData() async {
var data = await http.get('<https://example.com/data>');
print(data.body);
}
This code assumes that everything will work perfectly. If there's an issue (e.g., network error, server error), the app will crash or behave unpredictably. While this is simple, we all know that there will be always errors or issues.
Future<void> fetchData() async {
try {
var response = await http.get('<https://example.com/data>');
if (response.statusCode == 200) {
print(response.body);
} else {
print('Failed to load data: ${response.statusCode}');
}
} catch (e) {
print('Error occurred: $e');
}
}
Comprehensive error handling ensures the application can gracefully handle failures, improving the user experience. Checking the status code and catching exceptions makes the application more robust and reliable.
To further improve the quality and consistency of your Flutter code, you can use linting tools like DCM which comes with several lint rules that can help you to handle better error handling in your code base across your team. Rules such as avoid-only-rethrow
, avoid-throw-in-catch-block
and avoid-uncaught-future-errors
. These tools help enforce best practices and prevent common mistakes in error handling.
For example, avoid-only-rethrow
warns when a catch clause has only a rethrow expression.
void main() {
try {
...
} on Object catch (error) {
rethrow; // LINT
}
}
and it forces you to change it to a proper code handling the situation.
void main() {
try {
...
} on Object catch (error) {
if (error is Exception) {
// handle
return;
}
rethrow;
}
}
or the avoid-uncaught-future-errors
rule warns when an error from a Future inside a try/catch block might not be caught, which can lead to unexpected errors.
For example, consider the following code:
Future<void> asyncFunctionAssignFuture() async {
try {
final future = asyncFunctionError();
await Future.delayed(const Duration(seconds: 1));
await future; // LINT
} catch (e) {
print('caught in function: $e');
}
}
If a Future
that results in an error is awaited only after an async gap (ex. another Future
is being awaited), the error will be caught by the global error handler as well as the outer try / catch block. This behavior is by design and may lead to unexpected errors being shown. Using this rule enforces to handle this situation gracefully.
Future<void> asyncFunctionAssignFuture() async {
try {
final future = asyncFunctionError().catchError((error) { ... });
await Future.delayed(const Duration(seconds: 1));
await future;
} catch (e) {
print('caught in function: $e');
}
}
Several other rules can prevent such mistakes even though you will end up writing more code but it will contribute to a healthy codebase.
The Pitfall of “Magic” Code
"Magic" code refers to overly concise code and leverages obscure features, making it hard to understand and maintain. While magic code can be impressive in its brevity and cleverness, it often sacrifices readability and maintainability. Future developers (or even yourself) may need help understanding the logic, leading to increased chances of bugs and difficulties in extending the code.
Let’s explore the following example:
void processList(List<int> numbers) {
numbers.sort((a, b) => b.compareTo(a));
print(numbers.where((n) => n.isEven).map((n) => n * 2).join(', '));
}
This code sorts a list of integers in descending order, filters out the even numbers, doubles them, and prints them as a comma-separated string. While this one-liner is efficient, it is challenging to read and understand.
void processList(List<int> numbers) {
// Sort the list in descending order
numbers.sort((a, b) => b.compareTo(a));
// Filter even numbers
var evenNumbers = numbers.where((n) => n.isEven).toList();
// Double the even numbers
var doubledNumbers = evenNumbers.map((n) => n * 2).toList();
// Convert to a comma-separated string
var result = doubledNumbers.join(', ');
// Print the result
print(result);
}
Although longer, this version is much clearer. Each step is broken down and commented on, making it easy to follow the logic. This improves readability and maintainability, allowing future developers to understand and modify the code easily.
By avoiding magic code and opting for clear, explicit code, we create easier-to-read, understand, and maintain software, ultimately leading to higher quality and more reliable applications.
The Role of Reactivity
Reactivity in Flutter is a powerful feature, but it can sometimes lead to complex, less readable code. Proper management of reactive code can strike a balance between reactivity and readability. Reactive programming allows for efficient and responsive applications but requires careful management to avoid overly complex code. Clarity in reactive code can prevent hard-to-debug issues and make the codebase more maintainable.
Let’s see the StreamBuilder
as an example:
StreamBuilder(
stream: someStream,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(snapshot.data);
} else {
return CircularProgressIndicator();
}
},
);
This code is reactive and concise, but it doesn't gracefully handle various edge cases like errors or null data. Now, by extending this code, you can write much better reactive code:
class ReactiveWidget extends StatelessWidget {
Widget build(BuildContext context) {
return StreamBuilder(
stream: someStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else if (!snapshot.hasData) {
return Text('No data available');
} else {
return Text(snapshot.data);
}
},
);
}
}
By explicitly handling different states, we make the reactive code more transparent and robust. This version gracefully handles waiting, errors, and data absence, improving user experience and maintainability. We address each possible state of the snapshot
, making it clear what the application will do in each scenario.
Let me give you another example.
Nesting Streams
and Futures
can create scenarios where managing the flow of asynchronous data becomes difficult. This complexity can result in unexpected behavior, increased difficulty in handling errors, and challenges in debugging.
Using avoid-nested-streams-and-futures
DCM lint rule can help you prevent this scenario.
Here are examples of bad practices where Streams
contain Futures
and vice versa:
// LINT: Stream containing a Future
Stream<Future<int>> function() async* {
// Some asynchronous operations
}
// LINT: Future containing a Stream
Future<Stream<int>> function() async {
// Some asynchronous operations
}
In the above examples, the Stream
contains a Future<int>
, and the Future
returns a Stream<int>
. These nested types make it difficult to handle asynchronous operations cleanly.
Instead, it's recommended to keep Streams
and Futures
separate to maintain clean and manageable code. Here’s how you can refactor the previous examples to avoid nesting:
// Good: Stream returning an integer directly
Stream<int> function() async* {
// Some asynchronous operations
}
// Good: Future returning an integer directly
Future<int> function() async {
// Some asynchronous operations
}
By keeping Streams
and Futures
distinct, you ensure that your asynchronous code is easier to read and maintain, and you avoid the pitfalls associated with nested asynchronous types.
To enforce this best practice in your Flutter project, you can configure the lint rule in your dart_code_metrics
setup:
dart_code_metrics:
...
rules:
...
- avoid-nested-streams-and-futures
Balancing reactivity with readability ensures the application remains responsive and efficient without sacrificing clarity and maintainability. This approach helps prevent hard-to-debug issues and keeps the codebase clean and understandable.
Conclusion
It's clear that more code is not inherently bad and can contribute to better software in many ways. Clarity, modularity, flexibility, robustness, and proper error handling are crucial aspects of high-quality code that often require writing more lines. Avoiding magic code and managing reactivity carefully can also enhance maintainability. Finally, leveraging linting tools like DCM ensures consistent coding standards and best practices.
Check out DCM features to learn more about how DCM.dev can help you and your team save hours, improve the quality of your code, and continue building your apps confidently.