Chapter 9. Keeping things lightweight

This chapter covers

  • Using complexity measurements to identify code to refactor
  • Python language features for breaking up code
  • Using Python language features to support backward compatibility

In your software development, you’ll remain vigilant about separating concerns, but you’ll generally wait until a sensible organization presents itself in order to avoid creating the wrong abstractions. This means your classes will generally grow bit by bit until they become unruly.

This is quite like the art of training a bonsai tree; you need to give the tree time to grow, and only after it tells you where it’s headed can you encourage it down that path. Trimming the tree too often can stress it, and forcing it into an unnatural shape may stunt its ability to thrive.

In this chapter, you’ll learn how to prune your code to keep it healthy and thriving.

9.1. How big should my class/function/module be?

Many an online forum on software maintenance contains questions of this nature. I sometimes wonder if we keep asking because we think eventually we can transcend to some new plane of understanding, where the answer was obvious all along. Each ensuing discussion thread contains a mix of opinions, anecdotes, and occasional data points.

The desire to find a final answer to this question isn’t inherently bad; it’s useful to have guidelines and waypoints so you can recognize when you should invest time in your code. But it’s also important to understand the strengths and weaknesses of the metrics that we use to approach this question.

9.1.1. Physical size

Some folks attempt to prescribe a line limit for functions, methods, and classes. This metric seems nice because it’s readily measurable: “My function is 17 lines long.” I take issue with this approach because it can force a developer to break up a function that is otherwise perfectly understandable, increasing cognitive load.

If you draw a line in the sand at five lines, a six-line function is suddenly out of the question. This encourages developers to play “code golf,” trying to fit the same amount of logic into fewer lines. Python enables this kind of game too:

def valuable_customers(customers):
    return [customer for customer in customers if customer.active and
 sum(account.value for account in customer.accounts) > 1_000_000]

Were you able to make sense of that code immediately? It’s not awful, but does mashing it into one line add value?

Take a look at a rewritten version, where each clause is given its own line:

def valuable_customers(customers):
    return [
        customer
        for customer in customers
        if customer.active
        and sum(account.value for account in customer.accounts) > 1_000_000
    ]

Breaking things up logically gives someone reading your code a chance to digest each clause, forming a mental model of what’s happening as they go.

Another form of the line-limit rule I’ve seen is that “a class should fit on one screen.” This shares some of the pain points with its stricter version, while at the same time being less measurable due to different screen sizes and resolutions.

The spirit of these metrics is to “keep it simple,” with which I agree. But there are other ways to define “simple.”

9.1.2. Single responsibility

A more open-ended measurement of the size of a class, method, or function is how many different things it does. As you’ve learned from separation of concerns, the ideal number is one. For functions and methods, this means performing a single calculation or task. For classes, it means dealing with a single, focused facet of some larger business problem.

If you spot a function performing two tasks or a class that contains two distinct areas of focus, that’s a strong signal of an opportunity to separate them. But there may be times when what feels like a single task is still complex enough to warrant breaking down further.

9.1.3. Code complexity

One of the more robust ways of understanding the cognitive and maintenance impact of code is through its complexity. Like time and space complexity, code complexity is a quantitative measurement of the characteristics of your code, not just a subjective measure of how confused you get by reading it.

Complexity measurement tools are a great thing to have in your tool belt. I find that they often accurately point out code I would have trouble reading and understanding as a human. In the next few sections, I’ll show you what code complexity looks like, along with some tools for measuring it.

MEASURING CODE COMPLEXITY

A common measure of complexity is cyclomatic complexity. Although the name sounds scarily scientific, measuring cyclomatic complexity involves determining the number of execution paths through a function or method. The structure (and therefore, complexity) of a function is affected by the number of conditional expressions and loops it contains.

The higher the complexity score is for a function or method, the more conditionals and loops you should expect it to contain. The specific score isn’t always terribly useful; its trend over time, and how it changes in response to alterations you make in the code, is what will help you write more maintainable software. Seek to drive your complexity scores down over time, and consider pieces of code with high complexity when determining where to invest refactoring time.

You can measure the complexity of a function yourself. By creating a graph of the control flow, or the path the code takes as it executes, you can count the number of nodes and edges in the graph and calculate the cyclomatic complexity. The following are represented as nodes in the control flow graph of a program:

  • The “start” of the function (where the control flow enters)
  • if/elif/else conditions (each one is its own node)
  • for loops
  • while loops
  • The “end” of a loop (where you draw the execution path back to the start of the loop)
  • return statements

Consider the function in the following listing, which accepts a sentence as either a string or a list of words and determines whether the sentence has any long words in it. It contains a loop and multiple conditional expressions.

Listing 9.1. A function with conditionals and a loop
def has_long_words(sentence):
    if isinstance(sentence, str):          1
        sentence = sentence.split(' ')

    for word in sentence:                  2
        if len(word) > 10:                 3
            return True

    return False                           4

  • 1 Splits words in sentence if it’s a string (conditional)
  • 2 Does work for each word (loop)
  • 3 Returns True if a long word is found (conditional)
  • 4 Returns False if no words were long

The edges are arrows that follow the different execution paths your code can take. Cyclomatic complexity, M, for a function or method is equal to the number of edges minus the number of nodes, plus two. You can add nodes and edges for the lines of code that aren’t inside a conditional block or a loop if it helps you diagram a function, but they won’t affect the overall complexity—they each add one node and one edge, which cancel out in the math.

The has_long_words function has one conditional to check if the input is a string, a loop for each word in the sentence, and a conditional inside the loop to check if a word is long. Its diagram is shown in figure 9.1. By diagramming the control flow and simplifying the graph as plain nodes and edges, you can count them up and plug the results into the cyclomatic complexity equation. In this case, the graph of has_long_words has 8 nodes with 10 edges, so its complexity is M = E - N + 2 = 10 - 8 + 2 = 4.

Figure 9.1. Diagramming control flow to measure cyclomatic complexity

Most sources recommend shooting for a complexity of 10 or lower for a given function or method. This corresponds roughly to how much developers can reasonably understand at once.

In addition to helping you understand the health of your code, cyclomatic complexity is useful in testing. Recall that cyclomatic complexity measures the number of execution paths a function or method has. Consequently, this is also the minimum number of distinct test cases you would need to write to cover each execution path. This follows from the fact that each if, while, and so on requires you to prepare a different set of preconditions to test what happens in one case or the other.

Remember that perfect test coverage doesn’t guarantee that your code actually works; it only means your tests caused that part of the code to run. But making sure you cover the execution paths of interest is usually a good idea. Untested branches of execution are usually what people are referring to when they talk about “edge cases,” a term with negative connotations that usually means “a thing we didn’t think of.” The excellent Coverage package by Ned Batchelder (https://coverage.readthedocs.io) can print branch coverage metrics for your tests.

Halstead complexity

For some applications, reducing the risk of shipping defective software is as big a priority as maintainability. Although reducing branches in your code tends to make it more readable and understandable, it hasn’t been proven to reduce the number of bugs in software. Cyclomatic complexity predicts the number of defects about as well as the number of lines of code does. But there’s at least one set of metrics out there that tries to address the defect rate.

Halstead complexity attempts to measure quantitatively the ideas of level of abstraction, maintainability, and defect rate. Measuring Halstead complexity involves inspecting a program’s use of the programming language’s built-in operators and how many variables and expressions it contains. It’s beyond the scope of this book, but I recommend reading more about it. (The Wikipedia article is a good place to start: https://en.wikipedia.org/wiki/Halstead_complexity_measures.) Radon (https://radon.readthedocs.io) can measure the Halstead complexity of your Python programs if you’re interested in exploring.

Recall the code you wrote to import GitHub stars in Bark (reproduced in the following listing). Try to diagram the control flow and calculate the cyclomatic complexity.

Listing 9.2. The code for importing GitHub stars in Bark
def execute(self, data):
    bookmarks_imported = 0

    github_username = data['github_username']
    next_page_of_results =
 f'https://api.github.com/users/{github_username}/starred'

    while next_page_of_results:                   1
        stars_response = requests.get(
            next_page_of_results,
            headers={'Accept': 'application/vnd.github.v3.star+json'},
        )
        next_page_of_results = stars_response.links.get('next', {}).get('url')

        for repo_info in stars_response.json():   2
            repo = repo_info['repo']

            if data['preserve_timestamps']:       3
                timestamp = datetime.strptime(
                    repo_info['starred_at'],
                    '%Y-%m-%dT%H:%M:%SZ'
                )
            else:                                 4
                timestamp = None

            bookmarks_imported += 1
            AddBookmarkCommand().execute(
                self._extract_bookmark_info(repo),
                timestamp=timestamp,
            )                                     5

    return f'Imported {bookmarks_imported} bookmarks from starred repos!'

  • 1 A loop that code further down will come back to
  • 2 Another loop that code further down will come back to
  • 3 One branch of execution
  • 4 Another branch of execution
  • 5 The point that returns to the for, or, if complete, to the while

When you’re done, come back and check your work against the solution in figure 9.2.

Figure 9.2. The cyclomatic complexity of a function from the Bark application

Fortunately, you won’t need to diagram each function and method you write. A number of tools out there, like SonarQube (www.sonarqube.org) and Radon (https://radon.readthedocs.io), can measure these for you. These tools can even be integrated into your code editors so that you can break up complex code as you develop.

Now that you’ve learned some of the ways to discover when code has grown complex, you can get some practice breaking down that complexity.

9.2. Breaking down complexity

I have some mildly bad news: recognizing that code is complex is the easy part. The next challenge is understanding how to deal with specific kinds of complexity. Throughout the rest of this chapter, I’ll point out some common patterns of complexity I’ve seen during my travels with Python, and I’ll show you the options you have for tackling them.

9.2.1. Extracting configuration

I’ll start with an example you’ve already seen in this book: as your software grows, certain areas of the code need to continue adapting to new requirements.

Imagine you’re building a web service that indecisive users can query to see what they should eat for lunch. If a user goes to your service’s /random endpoint, they should get a random food, like pizza, in return. Your initial handler function accepts the user’s request as an argument, and it might look something like this:

import random

FOODS = [                         1
    'pizza',
    'burgers',
    'salad',
    'soup',
]

def random_food(request):         2
    return random.choice(FOODS)   3

  • 1 A list of foods (This could go in a database eventually.)
  • 2 The function accepts the user’s HTTP request (unused currently).
  • 3 Returns a random food from the list, as a string

When your service gets popular (people are all indecisive), some users want to build a full-fledged app around it. They tell you they want to get the response from you in JSON format because it’s easy to work with. You don’t want to change the default behavior for the rest of your users, so you tell them you’ll return a JSON response if they send an Accept: application/json header in their request. (Don’t worry much about how HTTP headers work if you’re not already familiar with them; assume that request.headers is a dictionary of header names to header values.) You could update your function to account for this:

import json
import random

...

def random_food(request):
    food = random.choice(FOODS)                               1

    if request.headers.get('Accept') == 'application/json':   2
        return json.dumps({'food': food})
    else:
        return food                                           3

  • 1 Chooses the food at random and stores it for use momentarily
  • 2 Returns {"food": "pizza"}, for example, if the request has the Accept: application/json header
  • 3 Continues returning “pizza”, for example, by default

Think about this change in terms of cyclomatic complexity; what is the complexity before and after the change?

  1. 1 before, 2 after
  2. 2 before, 2 after
  3. 1 before, 3 after
  4. 2 before, 1 after

Your initial function had no conditionals or loops, so the complexity was 1. Because you’ve added only one new condition (the case when the user requests JSON), the complexity has gone from 1 to 2 (option 1).

An increase of complexity by 1 to handle a new requirement isn’t terrible to start with. But if you continue on that trajectory for long, increasing complexity linearly with each requirement, you’ll soon be dealing with hairy code:

...

def random_food(request):
    food = random.choice(FOODS)
    if request.headers.get('Accept') == 'application/json':
        return json.dumps({'food': food})
    elif request.headers.get('Accept') == 'application/xml':   1
        return f'<response><food>{food}</food></response>'
    else:
        return food

  • 1 Each additional requirement is a new condition, increasing complexity.

Do you remember how to solve this? As a hint, observe that the conditionals are mapping a value (the value of the Accept header) to another value (the response to return). What data structure makes sense?

  1. list
  2. tuple
  3. dict
  4. set

A Python dictionary (option 3) maps values to other values, so it’s a good fit for refactoring this code. Remodeling the execution flow as a configuration of header values to response formats, and then choosing the right one based on the user’s request, will simplify things.

Try extracting the different header values and response types into a dictionary, using the default behavior as the fallback if the user doesn’t request a response format (or requests an unknown format). Check your work against the following listing when you’re done.

Listing 9.3. An endpoint with extracted configuration
...

def random_food(request):
    food = random.choice(FOODS)

    formats = {                                               1
        'application/json': json.dumps({'food': food}),
        'application/xml': f'<response><food>{food}</food></response>',
    }

    return formats.get(request.headers.get('Accept'), food)   2

  • 1 Extracted from the previous if/elif conditions
  • 2 Gets the requested response format if available; otherwise, falls back to returning the plain string

Believe it or not, this new solution is reduced back to a cyclomatic complexity of 1. And even if you continue adding entries to the formats dictionary, no additional complexity is added. This is the kind of gain I talked about in chapter 4; you’ve gone from a linear algorithm to a constant one.

Extracting configuration into a map also makes code much more readable, in my experience. Trying to sift through a number of if/elif conditions is tiresome, even when they’re all fairly similar. In contrast, a dictionary’s keys are generally scannable. If you know the key you’re looking for, it’s quick to spot.

Can we do even better?

9.2.2. Extracting functions

With the growing cyclomatic complexity defeated, two other things are still growing in tandem within the random_food function:

  • The code that knows what to do (format the response as JSON, XML, and so on)
  • The code that knows how to decide what to do (based on the Accept header values)

This is an opportunity to separate concerns. As I’ve advocated a few times in this book, extracting some functions here could be helpful. If you look at each item in the formats dictionary, you’ll notice that the value is a function of the food variable. Each of these values could be a function that accepts a food argument and returns the formatted response that will go back to the user, as shown in figure 9.3.

Figure 9.3. Extracting inline expressions as functions

Try changing your random_food function to use these separated response-format functions. The dictionary will now map formats to the function that can return the response for that format, and random_food will call that function with the food value. If no function is available after calling formats.get(…), you should fall back to a function that returns the food value unchanged; this can be done using a lambda. Check the following listing when you’re done.

Listing 9.4. A service endpoint with response-formatting functions
def to_json(food):                            1
    return json.dumps({'food': food})


def to_xml(food):
    return f'<response><food>{food}</food></response>'


def random_food(request):
    food = random.choice(FOODS)

    formats = {                               2
        'application/json': to_json,
        'application/xml': to_xml,
    }

    format_function = formats.get(            3
        request.headers.get('Accept'),
        lambda val: val                       4
    )
    return format_function(food)              5

  • 1 The extracted formatting functions
  • 2 Maps data formats to their respective formatting functions now
  • 3 Gets the appropriate formatting function if available
  • 4 Uses a lambda as the fallback to return the unchanged food value
  • 5 Calls the formatting function and returns its response

To fully separate the concerns, you can now extract formats and the business of getting the right function from it into its own function, get_format_function. This function accepts the user’s Accept header value and returns the right formatting function. Try that out now and refer to the following listing when you’re done to check your work.

Listing 9.5. Separating concerns into two functions
def get_format_function(accept=None):                                     1
    formats = {
        'application/json': to_json,
        'application/xml': to_xml,
    }

    return formats.get(accept, lambda val: val)


def random_food(request):                                                 2
    food = random.choice(FOODS)
    format_function = get_format_function(request.headers.get('Accept'))  3
    return format_function(food)

  • 1 Determines which formatting function to use
  • 2 random_food is three short steps now.
  • 3 Previously mixed concerns are abstracted to function calls now.

You may be thinking this code is more complex; you now have four functions compared to your initial one. But you’ve achieved something here: each of these functions has a cyclomatic complexity of 1, is quite readable, and has a nice separation of concerns.

You’ve also got something extensible on your hands, because when you need to handle new response formats, the process is as follows:

  1. Add a new function to format the response as desired.
  2. Add the mapping of the required Accept header value to the new formatting function.
  3. Profit.

You can create new business value just by adding new code and updating configuration. This is the ideal.

Now that you know some tricks for functions, I want to show you a few for classes.

9.3. Decomposing classes

Classes can grow unruly like functions, and perhaps at a faster rate. But it feels somehow more scary to break down a class than it does a function. Functions feel like building blocks, but classes feel like completed products. This is a mental barrier I often struggle to suppress.

You should have the confidence to decompose classes as frequently as functions. Classes are just another tool at your disposal. When you find that a class starts growing in complexity, it’s usually due to a mixing of concerns. Once you identify a concern that feels like its own object, you’ve got enough to start breaking it down.

9.3.1. Initialization complexity

I often see classes that have complex initialization procedures. For better or worse, these classes are usually complex because they deal with complex data structures. Have you ever seen a class like the following?

Listing 9.6. A class with complex domain logic in its construction
class Book:
    def __init__(self, data):
        self.title = data['title']                                 1
        self.subtitle = data['subtitle']

        if self.title and self.subtitle:                           2
            self.display_title = f'{self.title}: {self.subtitle}'
        elif self.title:
            self.display_title = self.title
        else:
            self.display_title = 'Untitled'

  • 1 Extracts some fields from the passed-in data
  • 2 Complexity arising from the domain logic of your business

When the domain logic you’re dealing with is complex, your code is more likely to reflect that. In these cases, it’s more important than ever for developers to rely on useful abstractions to make sense of it all.

I’ve talked about extracting functions and methods as a useful way to break down code. One approach you could take here is to extract the logic for display_title into a set_display_title method that you could call from the __init__ method, as shown in the following listing. Try creating a book module and adding the Book class to it, extracting a setter method for display_title.

Listing 9.7. Using a setter to simplify class construction
class Book:
    def __init__(self, data):
        self.title = data['title']
        self.subtitle = data['subtitle']
        self.set_display_title()            1

    def set_display_title(self):            2
        if self.title and self.subtitle:
            self.display_title = f'{self.title}: {self.subtitle}'
        elif self.title:
            self.display_title = self.title
        else:
            self.display_title = 'Untitled'

  • 1 Calls the extracted function
  • 2 Extracted function sets display_title.

This has cleaned up the __init__ method, but a couple of issues arise from this approach:

  • Getters and setters are generally discouraged in Python because they can clutter up a class.
  • It’s good practice to set all necessary attributes to some initial value directly inside __init__, but display_title is set in a different method.

You could fix the latter by setting display_title to 'Untitled' by default, but this can be misleading. A reader might conclude the display title is typically (or even always) 'Untitled', if they don’t read carefully.

There is one approach that can give you the readability benefit of extracting a method, without suffering these drawbacks. It involves creating a function that returns the value for display_title.

But wait! If you think about how you use Book, it might be something like this:

...

book = Book(data)
return book.display_title

How can you make the display_title logic a function without having to update the second line to return book.display_title() instead? Fortunately, Python provides a tool for this occasion. The @property decorator can be used to signify that a method on a class should be accessible as an attribute.

Create a display_title method now, decorated with @property, that uses the existing logic to return the proper display title. Compare your changes with the following listing when you’re done.

Note

Methods can be used as properties only if self is their only argument, because when you access the attribute, you can’t pass any arguments to it.

Listing 9.8. Using @property to simplify class construction
class Book:
    def __init__(self, data):
        self.title = data['title']
        self.subtitle = data['subtitle']

    @property
    def display_title(self):                 1
        if self.title and self.subtitle:
            return f'{self.title}: {self.subtitle}'
        elif self.title:
            return self.title
        else:
            return 'Untitled'

  • 1 A property is a function that can be referenced as an attribute.

Using @property, you can still reference book.display_title as an attribute, but all its complexity is abstracted into its own function. This reduces the complexity of the __init__ method, making it more readable at the same time. I make frequent use of @property in my own code.

Note

Because properties are methods, repeatedly accessing them means that the methods are called each time. This is often okay, but it can have performance impacts for properties that are expensive to calculate.

What should you do when there’s enough functionality to abstract a whole class worth of methods?

9.3.2. Extracting classes and forwarding calls

When you extracted get_format_function from random_food in section 9.2.2, you still called the extracted function from its original location. When dealing with classes, something similar will need to happen if you want to maintain backward compatibility. Backward compatibility is the practice of evolving your software without breaking the implementation consumers previously relied on. If you change the arguments of a function, the name of a class, and so on, consumers will need to update their code if they want it to continue working. To avoid these problems, you could take a hint from the post office’s mail forwarding system.

When you move to a new address, you can tell the post office to forward your mail (figure 9.4). People who send you mail at your old address don’t need to know your new address immediately because the post office will intercept the mail and direct it to you automatically. Each time you receive a piece of mail addressed to your old residence, you can notify the sender of your new address so they can update their records. Once you’re confident you aren’t receiving mail made out to the old address any longer, you can stop the post office forwarding.

Figure 9.4. Mail can be forwarded by the post office when you move to a new location.

When you extract one class from another, you’ll want to continue providing the previously existing functionality for a while, despite changing things under the hood, so that consumers don’t need to immediately worry about upgrading their software. As with your mail, you can continue accepting calls in one class and pass them along to another class under the hood. This is known as forwarding.

Suppose your Book class has grown to keep track of the author information. This feels natural at the start; what is a book without its author? But as the class takes on more functionality, the author starts to feel like a separate concern. As shown in the following listing, methods soon exist for the author’s name as it should be displayed on a website, as well as how it should be displayed in a research paper citation.

Listing 9.9. A Book class too concerned with author details
class Book:
    def __init__(self, data):
        # ...
        self.author_data = data['author']         1

    @property
    def author_for_display(self):                 2
        return f'{self.author_data["first_name"]}
 {self.author_data["last_name"]}'

    @property
    def author_for_citation(self):                3
        return f'{self.author_data["last_name"]},
 {self.author_data["first_name"][0]}.'

Suppose you’d been using this Book class like so:

book = Book({
    'title': 'Brillo-iant',
    'subtitle': 'The pad that changed everything',
    'author': {
        'first_name': 'Rusty',
        'last_name': 'Potts',
    }
})


print(book.author_for_display)
print(book.author_for_citation)

  • 1 Stores the author as a dictionary from the data
  • 2 Displays the author, such as “Dane Hillard”
  • 3 Gets the citation-suitable author name, such as “Hillard, D”

Being able to reference book.author_for_display and book.author_for_citation has been great, and you’d like to keep that. But referencing the author dictionary in those properties is starting to feel clumsy, and you know that you’ll want to do a lot more with authors soon. How do you proceed?

  1. Extract an AuthorFormatter class for formatting author names in different ways.
  2. Extract an Author class to encapsulate author behaviors and information.

Although a class for formatting author names (option 1) might provide value, extracting an Author class (option 2) provides a better separation of concerns. When several methods in a class share a common prefix or suffix, especially one that doesn’t match the name of the class, there might be a new class waiting to be extracted. Here, author_ is a sign that an Author class might make sense. It’s time to try your hand at extracting a class.

Create an Author class (either in the same module or imported from a new module). This Author class should contain all the same information as before, but in a more structured manner. The class should

  • Accept author_data as a dictionary in __init__, storing each relevant value (first name, last name, and so on) from the dictionary as an attribute
  • Have two properties, for_display and for_citation, that return the properly formatted author string

Remember that you also want Book to keep working for users, so you want to keep the existing author_data, author_for_display, and author_for_citation behaviors on Book for now. By initializing an Author instance with author_data, you can forward calls from Book.author_for_display to Author.for_display, and so on. This way, Book will let Author do most of the work, keeping only a temporary system in place to make sure calls keep working. Give it a try now, and come back to the following listing to see how you did.

Listing 9.10. Extracting an Author class from the Book class
class Author:
    def __init__(self, author_data):                   1
        self.first_name = author_data['first_name']
        self.last_name = author_data['last_name']

    @property
    def for_display(self):                             2
        return f'{self.first_name} {self.last_name}'

    @property
    def for_citation(self):
        return f'{self.last_name}, {self.first_name[0]}.'


class Book:
    def __init__(self, data):
        # ...

        self.author_data = data['author']              3
        self.author = Author(self.author_data)         4

    @property
    def author_for_display(self):                      5
        return self.author.for_display

    @property
    def author_for_citation(self):
        return self.author.for_citation

  • 1 What was previously stored only as a dictionary is now structured attributes.
  • 2 The Author-level properties are simpler than the originals.
  • 3 Continues storing author_data until consumers don’t need it anymore
  • 4 Stores an instance of Author for forwarding calls
  • 5 Replaces previous logic with forwarding to the Author instance

Do you notice that even though the code now has more lines, each line has been simplified? And looking at the classes, it’s a bit easier to tell what kind of information they contain. Eventually, much of the code still in Book will also be removed, at which point Book will be leveraging composition of the Author class to provide information about its authors.

If you want to be really nice to your consumers as you decompose a class, you can also leave them hints so they know they should switch to the new code. For example, you want the consumers of Book to move from book.author_for_display to book.author.for_display so that you can remove the forwarding. Python has a built-in system for this kind of messaging, called warnings.

One type of warning is specifically a DeprecationWarning, which you can use to let people know that something should no longer be used. This warning generally prints a message in a program’s output telling the user they should make a change. A deprecation warning can be produced as follows:

import warnings

warnings.warn('Do not use this anymore!', DeprecationWarning)

You can help consumers upgrade their code smoothly by adding a DeprecationWarning to each method you eventually want to remove.[1] Try adding them to the author-related properties in the Book class now. You can say something useful like 'Use book.author .for_display instead'. If you run the code now, you should see warning messages in the output that look like the following:

1

See Brett Slatkin, “Refactoring Python: Why and how to restructure your code,” PyCon 2016, www.youtube.com/watch?v=D_6ybDcU5gc, for a treasure trove of deprecation and extraction tricks.

/path/to/book.py:24: DeprecationWarning: Use book.author.for_display instead

Congratulations! You’ve extracted a new class, breaking down the complexity of a class that outgrew itself. You did it in a backward-compatible way, leaving hints for users so they know what’s coming and how to fix it. This resulted in more structured, more readable code with separate concerns and strong cohesion. Well done, you.

Summary

  • Code complexity and separate concerns are better metrics than physical size for breaking up code.
  • Cyclomatic complexity measures the number of execution paths through your code.
  • Extract configuration, functions, methods, and classes freely to break down complexity.
  • Use forwarding and deprecation warnings to temporarily support the new and old ways of doing things.
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.144.37.196