Cyclomatic complexity is counted as a very useful metric in software engineering for computing the complexity of control flow in programs. Roughly speaking, if anything, it is a metric describing the quantity of linearly independent paths flowing through something; in other words, code that is problematic for reasoning, testing, and modifying.
This article will review what high cyclomatic complexity means, how to detect and handle it using the CAST AIP tool, and best practices for readable and maintainable Python code.
Understanding Cyclomatic Complexity
It was McCabe who, in 1976, introduced the so-called cyclomatic complexity - a metric that counts the number of linearly independent paths through other sources of a program. In other words, any given metric is computed by means of a program control flow graph: nodes correspond to blocks of code, while edges correspond to control flow paths between such blocks.
In Python, the following constructs contribute to the cyclomatic complexity: if, for, while, try, except, and function definitions. The more decision points a function has, the higher its cyclomatic complexity.
Calculation of Cyclomatic Complexity:
For a function f
with:
E
as the number of edges in the control flow graph,N
as the number of nodes,P
as the number of connected components (which is usually 1 for a single function),The cyclomatic complexity M
is calculated as:
M=E−N+2PM = E - N + 2PM=E−N+2P
Alternatively, it can be approximated by counting the number of decision points in the code plus one.
Example of Cyclomatic Complexity Calculation:
def example_function(x):
if x > 0:
print("Positive")
elif x < 0:
print("Negative")
else:
print("Zero")
In this example:
if
, elif
, else
): 2M
): 2 + 1 = 3
Implications of High Cyclomatic Complexity
High cyclomatic complexity in Python functions or methods can have several negative consequences:
Low Readability: Complex functions are filled with many decision points and nested logic, hence complex to read and understand. It increases code review time and makes the occurrence of bugs probable.
Difficult to Test: The greater the cyclomatic complexity, the more test cases are required to achieve comprehensive test coverage. A function like this would require at least 10 different test cases to cover all the possible paths, which is quite cumbersome and time-consuming to implement and maintain.
More Costly Maintenance: The more complex the functionality is, the harder it is to adapt or extend without introducing new bugs. Developers may need more time to understand the code and its potential side effects, leading to increased maintenance costs.
Identifying High Cyclomatic Complexity with CAST AIP
CAST AIP is a software analysis tool that can provide the necessary information related to code artifacts containing high cyclomatic complexity. This would allow developers to focus their refactoring efforts on such locations. CAST AIP can detect functions and methods whose cyclomatic complexity exceeds recommended thresholds by analyzing the control flow of Python code.
Description: CAST AIP analyzes Python source code to compute cyclomatic complexity and identifies functions or methods with high complexity. These artifacts are marked as issues that need refactoring to enhance the quality and maintainability of code.
Rationale: The high cyclomatic complexity of their application has been maintained for reasons of improving code readability by lessening the possibility of bugs, performing appropriate testing of the software in order to lessen maintenance cost. Thus, complex code is easier to understand, test, and modify; hence, it provides more robust applications that are easy to maintain.
Remediation: High cyclomatic complexity can be remediated by refactoring complex functions into smaller, more focused functions, removing branches that are not needed, or using polymorphism instead of complex conditional logic.
Code Examples: High Cyclomatic Complexity and Refactoring Strategies
Here are some examples to illustrate high cyclomatic complexity in Python code and how to refactor it for better maintainability.
Example 1: High Cyclomatic Complexity in a Function
def process_data(data):
if data is None:
print("No data provided")
return
if isinstance(data, list):
if len(data) == 0:
print("Empty list")
else:
for item in data:
if isinstance(item, int):
print(f"Integer: {item}")
elif isinstance(item, str):
print(f"String: {item}")
else:
print("Unknown type")
elif isinstance(data, dict):
if not data:
print("Empty dictionary")
else:
for key, value in data.items():
if isinstance(value, int):
print(f"{key}: Integer {value}")
elif isinstance(value, str):
print(f"{key}: String {value}")
else:
print(f"{key}: Unknown type")
else:
print("Unsupported data type")
Cyclomatic Complexity Analysis:
if
, elif
, and nested conditions.Problems with High Complexity:
Refactoring to Reduce Complexity:
def process_data(data):
if data is None:
print("No data provided")
return
if isinstance(data, list):
process_list(data)
elif isinstance(data, dict):
process_dict(data)
else:
print("Unsupported data type")
def process_list(data):
if not data:
print("Empty list")
return
for item in data:
print_item(item)
def process_dict(data):
if not data:
print("Empty dictionary")
return
for key, value in data.items():
print_item(value, prefix=f"{key}: ")
def print_item(item, prefix=""):
if isinstance(item, int):
print(f"{prefix}Integer: {item}")
elif isinstance(item, str):
print(f"{prefix}String: {item}")
else:
print(f"{prefix}Unknown type")
Benefits of Refactoring:
Example 2: Using Polymorphism to Reduce Complexity
High cyclomatic complexity often arises from complex conditional logic that can be replaced with polymorphism. Consider the following example:
class Shape:
def draw(self):
pass
def render_shape(shape):
if isinstance(shape, Circle):
print("Drawing a circle")
elif isinstance(shape, Square):
print("Drawing a square")
elif isinstance(shape, Triangle):
print("Drawing a triangle")
else:
print("Unknown shape")
Cyclomatic Complexity Analysis:
if-elif
statements to handle different shapes.Refactoring Using Polymorphism:
class Shape:
def draw(self):
raise NotImplementedError("Subclasses should implement this!")
class Circle(Shape):
def draw(self):
print("Drawing a circle")
class Square(Shape):
def draw(self):
print("Drawing a square")
class Triangle(Shape):
def draw(self):
print("Drawing a triangle")
def render_shape(shape):
shape.draw() # Polymorphic call
Benefits of Refactoring:
render_shape
function no longer contains any decision points, reducing cyclomatic complexity to 1.draw
method, without modifying existing code.Example 3: Removing Unnecessary Branches
Sometimes, high cyclomatic complexity is due to unnecessary or redundant branches that can be eliminated.
def compute_discount(price, discount):
if discount < 0 or discount > 100:
print("Invalid discount")
return price
if price <= 0:
print("Invalid price")
return 0
return price * (1 - discount / 100)
Cyclomatic Complexity Analysis:
Simplified Function Without Redundant Checks:
def compute_discount(price, discount):
if not (0 <= discount <= 100):
raise ValueError("Discount must be between 0 and 100")
if price <= 0:
raise ValueError("Price must be greater than 0")
return price * (1 - discount / 100)
Benefits of Refactoring:
Best Practices to Reduce Cyclomatic Complexity
To effectively manage and reduce cyclomatic complexity in Python, consider the following best practices:
Refactor Long Functions: Break down long functions with high complexity into smaller, more manageable pieces. Each function should have a single responsibility.
Use Early Returns: Instead of using deeply nested if-else
structures, use early returns to handle edge cases and exit the function early. This reduces nesting and improves readability.
Leverage Polymorphism: Replace complex conditional logic with polymorphism where appropriate. This simplifies code and reduces the need for multiple decision points.
Remove Redundant Logic: Eliminate unnecessary branches or checks in your code. Simplify conditions wherever possible to reduce complexity.
Adopt Defensive Programming: Validate inputs early and raise exceptions as needed to avoid complex error handling later in the code. This approach makes the code cleaner and reduces complexity.
Use Enumerations and Data Structures: In the presence of multiple states or types, it is generally better to use enumerations or data structures that map states to actions rather than chains of if-elif.
Conclusion
Following best practices for low cyclomatic complexity in Python development will result in maintainable, readable, testable applications. Large function decomposition, applying polymorphism, and making use of early returns are some of the best ways developers can reduce high-level complexity and provide higher-quality code. CAST AIP helps you find those areas where cyclomatic complexity is high and where further changes toward good practices will improve the robustness and maintainability of your Python codebase.