본문 바로가기
Mastering Python Design Patterns

The Command Pattern

by 자동매매 2023. 3. 22.

The Command Pattern

Most applications nowadays have an undo operation. It is hard to imagine, but undo did not exist in any software for many years. Undo was introduced in 1974 (j.mp/wiundo), but Fortran and Lisp, two programming languages that are still widely used, were created in 1957 and 1958, respectively (j.mp/proghist)! I wouldn't like to have been an application

user during those years. Making a mistake meant that the user had no easy way to fix it.

Enough with the history. We want to know how we can implement the undo functionality in our applications. And since you have read the title of this chapter, you already know which design pattern is recommended to implement undo: the Command pattern.

The Command design pattern helps us encapsulate an operation (undo, redo, copy, paste, and so forth) as an object. What this simply means is that we create a class that contains all the logic and the methods required to implement the operation. The advantages of doing this are as follows (j.mp/cmdpattern):

  • We don't have to execute a command directly. It can be executed at will.
    • The object that invokes the command is decoupled from the object that knows how to perform it. The invoker does not need to know any implementation details about the command.
      • If it makes sense, multiple commands can be grouped to allow the invoker to execute them in order. This is useful, for instance, when implementing a multilevel undo command.

In this chapter, we will discuss:

  • Real-world examples
    • Use cases
      • Implementation

The Command Pattern Chapter 122*

Real-world examples

When we go to a restaurant for dinner, we give the order to the waiter. The check (usually paper) that they use to write the order on is an example of a command. After writing the order, the waiter places it in the check queue that is executed by the cook. Each check is independent and can be used to execute many different commands, for example, one command for each item that will be cooked.

As you would expect, we also have several examples in software. Here are two I can think of:

  • PyQt is the Python binding of the QT toolkit. PyQt contains a QAction class that models an action as a command. Extra optional information is supported for every action, such as description, tooltip, shortcut, and more (j.mp/qaction).

pattern to modify the model, amend a commit, apply a different election, check out, and so forth (j.mp/git-cola-code).

Use cases

Many developers use the undo example as the only use case of the Command pattern. The truth is that undo is the killer feature of the Command pattern. However, the Command pattern can actually do much more (j.mp/commddp):

  • GUI buttons and menu items: The PyQt example that was already mentioned uses the Command pattern to implement actions on buttons and menu items.
    • Other operations: Apart from undo, commands can be used to implement any operation. A few examples are cut, copy, paste, redo, and capitalize text.
      • Transactional behavior and logging: Transactional behavior and logging are important to keep a persistent log of changes. They are used by operating systems to recover from system crashes, relational databases to implement transactions, filesystems to implement snapshots, and installers (wizards) to revert canceled installations.
        • Macros: By macros, in this case, we mean a sequence of actions that can be recorded and executed on demand at any point in time. Popular editors such as Emacs and Vim support macros.

Implementation

In this section, we will use the Command pattern to implement the most basic file utilities:

  • Creating a file and optionally writing text (a string) to it
    • Reading the contents of a file
      • Renaming a file
        • Deleting a file

We will not implement these utilities from scratch, since Python already offers good implementations of them in the os module. What we want is to add an extra abstraction level on top of them so that they can be treated as commands. By doing this, we get all the advantages offered by commands.

From the operations shown, renaming a file and creating a file support undo. Deleting a file and reading the contents of a file do no support undo. Undo can actually be implemented on delete file operations. One technique is to use a special trash/wastebasket directory that stores all the deleted files, so that they can be restored when the user requests it. This is the default behavior used on all modern desktop environments and is left as an exercise.

Each command has two parts:

The initialization part: It is taken care of by the __init__() method and

contains all the information required by the command to be able to do something useful (the path of a file, the contents that will be written to the file, and so forth).

  • The execution part: It is taken care of by the execute() method. We call the execute() method when we want to actually run a command. This is not necessarily right after initializing it.

Let's start with the rename utility, which is implemented using the RenameFile class. The __init__() method accepts the source (src) and destination (dest) file paths as parameters (strings). If no path separators are used, the current directory is used to create the file. An example of using a path separator is passing the /tmp/file1 string as src and

the /home/user/file2 string as dest. Another example, where we would not use a path, is passing file1 as src and file2 as dest:

class RenameFile:

def __init__(self, src, dest): self.src = src

self.dest = dest

We add the execute() method to the class. This method does the actual renaming using os.rename(). The verbose variable corresponds to a global flag, which, when activated (by default, it is activated) gives feedback to the user about the operation that is performed. You can deactivate it if you prefer silent commands. Note that although print() is good enough for an example, normally something more mature and powerful can be used, for example, the logging module (j.mp/py3log):

def execute(self):

if verbose:

print(f"[renaming '{self.src}' to '{self.dest}']") os.rename(self.src, self.dest)

Our rename utility (RenameFile) supports the undo operation through its undo() method. In this case, we use os.rename() again to revert the name of the file to its original value:

def undo(self):

if verbose:

print(f"[renaming '{self.dest}' back to '{self.src}']") os.rename(self.dest, self.src)

In this example, deleting a file is implemented in a function, instead of a class. That is to show it is not mandatory to create a new class for every command that you want to add (more on that will be covered later). The delete_file() function accepts a file path as a string and uses os.remove() to delete it:

def delete_file(path):

if verbose:

print(f"deleting file {path}") os.remove(path)

Back to using classes again. The CreateFile class is used to create a file. The __init__() method for that class accepts the familiar path parameter and a txt parameter for the content (a string) that will be written to the file. If nothing is passed as txt, the default hello world text is written to the file. Normally, the sane default behavior is to create an empty file, but for the needs of this example, I decided to write a default string in it.

The definition of the CreateFile class starts as follows:

class CreateFile:

def __init__(self, path, txt='hello world\n'): self.path = path

self.txt = txt

Then we add an execute() method, in which we use the with statement and Python's open() built-in function to open the file (mode='w' means write mode), and the write() function to write the txt string to it, as follows:

def execute(self):

if verbose:

print(f"[creating file '{self.path}']")

with open(self.path, mode='w', encoding='utf-8') as out_file: out_file.write(self.txt)

The undo for the operation of creating a file is to delete that file. So, the undo() method, which we add to the class, simply uses the delete_file() function to achieve that, as follows:

def undo(self): delete_file(self.path)

The last utility gives us the ability to read the contents of a file. The execute() method of the ReadFile class uses open() again, this time in read mode, and just prints the content of the file using print().

The ReadFile class is defined as follows:

class ReadFile:

def __init__(self, path): self.path = path

def execute(self):

if verbose:

print(f"[reading file '{self.path}']")

with open(self.path, mode='r', encoding='utf-8') as in_file: print(in_file.read(), end='')

The main() function makes use of the utilities we have defined. The orig_name and new_name parameters are the original and new name of the file that is created and renamed. A commands list is used to add (and configure) all the commands that we want to execute at a later point. Note that the commands are not executed unless we explicitly call execute() for each command:

def main():

orig_name, new_name = 'file1', 'file2' commands = ( CreateFile(orig_name), ReadFile(orig_name),

RenameFile(orig_name, new_name) )

[c.execute() for c in commands]

The next step is to ask the users if they want to undo the executed commands or not. The user selects whether the commands will be undone or not. If they choose to undo them, undo() is executed for all commands in the commands list. However, since not all commands support undo, exception handling is used to catch (and ignore) the AttributeError exception generated when the undo() method is missing. The code

would look like the following:

answer = input('reverse the executed commands? [y/n] ') if answer not in 'yY':

print(f"the result is {new_name}")

exit()

for c in reversed(commands):

try:

c.undo()

except AttributeError as e:

print("Error", str(e))

Using exception handling for such cases is an acceptable practice, but if you don't like it, you can check explicitly whether a command supports the undo operation by adding a Boolean method, for example, supports_undo() or can_be_undone(). Again, that is not mandatory.

Here's the full code of the example (command.py):

  1. We import the os module and we define the constant we need:

import os verbose = True

  1. Here, we define the class for the rename file operation:

class RenameFile:

def __init__(self, src, dest):

self.src = src

self.dest = dest

def execute(self):

if verbose:

print(f"[renaming '{self.src}' to '{self.dest}']") os.rename(self.src, self.dest)

def undo(self):

if verbose:

print(f"[renaming '{self.dest}' back to '{self.src}']") os.rename(self.dest, self.src)

  1. Here, we define the class for the create file operation:

class CreateFile:

def __init__(self, path, txt='hello world\n'): self.path = path

self.txt = txt

def execute(self):

if verbose:

print(f"[creating file '{self.path}']")

with open(self.path, mode='w', encoding='utf-8') as out_file:

out_file.write(self.txt)

def undo(self):

delete_file(self.path)

  1. We also define the class for the read file operation, as follows:

class ReadFile:

def __init__(self, path): self.path = path

def execute(self):

if verbose:

print(f"[reading file '{self.path}']")

with open(self.path, mode='r', encoding='utf-8') as in_file:

print(in_file.read(), end='')

  1. And for the delete file operation, we decide to use a function (and not a class), as follows:

def delete_file(path):

if verbose:

print(f"deleting file {path}") os.remove(path)

  1. Here is the main part of the program now:

def main():

orig_name, new_name = 'file1', 'file2'

commands = (

CreateFile(orig_name),

ReadFile(orig_name),

RenameFile(orig_name, new_name)

)

[c.execute() for c in commands]

answer = input('reverse the executed commands? [y/n] ') if answer not in 'yY':

print(f"the result is {new_name}")

exit()

for c in reversed(commands):

try:

c.undo()

except AttributeError as e:

print("Error", str(e))

if __name__ == "__main__":

main()

Let's see two sample executions using the python command.py command line. In the first one, there is no undo of commands:

In the second one, we have the undo of commands:

But wait! Let's see what can be improved in our command implementation example. Among the things to consider are the following:

  • What happens if we try to rename a file that doesn't exist?
    • What about files that exist but cannot be renamed because we don't have the proper filesystem permissions?

We can try improving the utilities by doing some kind of error handling. Checking the return status of the functions in the os module can be useful. We could check if the file exists before trying the delete action, using the os.path.exists() function.

Also, the file creation utility creates a file using the default file permissions as decided by the filesystem. For example, in POSIX systems, the permissions are -rw-rw-r--. You

might want to give the ability to the user to provide their own permissions by passing the appropriate parameter to CreateFile. How can you do that? Hint: one way is by using os.fdopen().

And now, here's something for you to think about. I mentioned earlier that a command does not necessarily need to be a class. That's how the delete utility was implemented; there is just a delete_file() function. What are the advantages and disadvantages of this

approach? Here's a hint: Is it possible to put a delete command in the commands list as was done for the rest of the commands? We know that functions are first-class citizens in Python, so we can do something such as the following (see the first-class.py file):

import os verbose = True

class CreateFile:

def __init__(self, path, txt='hello world\n'): self.path = path

self.txt = txt

def execute(self):

if verbose:

print(f"[creating file '{self.path}']")

with open(self.path, mode='w', encoding='utf-8') as out_file: out_file.write(self.txt)

def undo(self):

try:

delete_file(self.path)

except:

print('delete action not successful...')

print('... file was probably already deleted.')

def delete_file(path):

if verbose:

print(f"deleting file {path}...") os.remove(path)

def main():

orig_name = 'file1' df=delete_file

commands = [CreateFile(orig_name),] commands.append(df)

for c in commands:

try:

c.execute()

except AttributeError as e: df(orig_name)

for c in reversed(commands): try:

c.undo()

except AttributeError as e: pass

if __name__ == "__main__":

main()

Although this variant of the implementation example works, there are still some issues:

  • The code is not uniform. We rely too much on exception handling, which is not the normal flow of a program. While all the other commands we implemented have an execute() method, in this case, there is no execute().
    • Currently, the delete file utility has no undo support. What happens if we eventually decide to add undo support for it? Normally, we add an undo() method in the class that represents the command. However, in this case, there is no class. We could create another function to handle undo, but creating a class is a better approach.

Summary

In this chapter, we covered the Command pattern. Using this design pattern, we can encapsulate an operation, such as copy/paste, as an object. This offers many benefits, as follows:

  • We can execute a command whenever we want, and not necessarily at creation time
    • The client code that executes a command does not need to know any details about how it is implemented
      • We can group commands and execute them in a specific order

Executing a command is like ordering at a restaurant. Each customer's order is an independent command that enters many stages and is finally executed by the cook.

Many GUI frameworks, including PyQt, use the Command pattern to model actions that can be triggered by one or more events and can be customized. However, Command is not limited to frameworks; normal applications such as git-cola also use it for the benefits it offers.

Although the most advertised feature of command by far is undo, it has more uses. In general, any operation that can be executed at the user's will at runtime is a good candidate to use the Command pattern. The command pattern is also great for grouping multiple commands. It's useful for implementing macros, multilevel undoing, and transactions. A transaction should either succeed, which means that all operations of it should succeed (the commit operation), or it should fail completely if at least one of its operations fails (the rollback operation). If you want to take the Command pattern to the next level, you can work on an example that involves grouping commands as transactions.

To demonstrate command, we implemented some basic file utilities on top of Python's os module. Our utilities supported undo and had a uniform interface, which makes grouping commands easy.

The next chapter covers the Observer pattern.

[ 122 ])

  • 1

'Mastering Python Design Patterns' 카테고리의 다른 글

The State Pattern  (0) 2023.03.22
The Observer Pattern  (0) 2023.03.22
The Chain of Responsibility  (0) 2023.03.22
Other Structural Patterns  (0) 2023.03.22
The Facade Pattern  (0) 2023.03.22

댓글