객체에서 다른 객체로 request를 보낼 때, request 내용이 복잡해 지거나 확장이 필요하면, 프로토콜을 따로 설계하게된다. 그렇게 해야 보다 유연해지기 때문이다. request에 대한 프로토콜을 별도의 Command 오브젝트로 구현하는 방법이 바로 Command Pattern이다. request를 보내는 Invoker 객체는 이를 받는 Reciever 객체에 대한 정보가 없어도 되기 때문에(Command 객체가 갖고있다), 두 객체간 coupling을 제거할 수 있다.
Command Pattern의 실제 예로는 Qt에서 QAction을 들 수 있다. 이 클래스가 Command 클래스에 해당한다. 명령어를 QAction 객체로 생성하고, 메뉴나 툴바에 이를 할당해주면 메뉴가 실행되거나 툴바 버튼이 눌릴 때, 이 QAction 객체가 수행된다. QAction 객체는 생성할 때, Receiver 객체에 대한 정보가 주어지므로 메뉴나 툴바에서 Reciever를 몰라도, 자연스럽게 Reciever로 전달되게 된다.
Command Pattern을 사용하면 여러 장점이 있다. Command 자체가 독립적이므로, 스케쥴러의 알람처럼 command의 수행을 바로하지 않고 특정시점으로 유보도 가능하다. 또, undo 기능을 이 패턴으로 구현하면, 커맨드 별로 undo를 진행하여 multi-level undo의 구현이 용이해진다. undo의 구현이 쉽지많은 않기 때문에, 실제 undo의 구현은 Memento pattern의 사용을 고려해야 한다.
많이 사용되는 패턴으로, 그 많은 용도들은 위키 페이지 Uses 항목에서 확인 가능하다. 또한, 좀 더 자세한 설명은 refactoring.guru 에서도 찾아볼 수 있다.
UML 다이어그램은 다음과 같다. ( 위키 페이지 참조 )

Invoker는 Reciever에 대한 정보를 갖지 않는다. Receiver는 Concrete Command 객체가 가지고 있다. Command Interface는 Invoker가 호출할 execute() 단일 인터페이스를 갖는다. Invoker에 Concrete command를 할당해주는건 Client 객체가 해주는 일이다. Command 객체는 execute()가 호출되면, Reciever의 action()과 같은 특정 operation을 하도록 호출한다. Receiver 또한, 인터페이스만 노출시키고 이를 Command가 사용하기 때문에, Receiver가 Command에 대해 알 필요도 없다.
예를 들어, Qt에서 버튼(Invoker)를 만들었다면, QAction(Command)를 생성해서 버튼에 할당해주는 작업을 Client 코드에서 하게된다. Command Pattern과 정확하게 매칭되지는 않지만, Qt의 메뉴구현 내용을 참고하자면 다음과 같이 구현한다.
...
menubar = self.menuBar()
filemenu = menubar.addMenu("&File")
# exit action을 생성
exit_action = QAction(QIcon("../resources/exit_kr.png"), "E&xit", self)
exit_action.setShortcut(QKeySequence(Qt.CTRL + Qt.Key_Q))
exit_action.setStatusTip("Exit application window")
# signal-slot 모델을 쓰므로 reciever로 호출될 메소드가 설정
exit_action.triggered.connect(self.my_exit)
# File 메뉴에 서브항목으로 정의한 exit QAction을 추가한다.
filemenu.addAction(exit_action)
...
def my_exit(self):
print("menu exit !!")
QApplication.quit()
...
action의 triggered가 execute에 해당하게 되고, connect로 사용자 정의 메소드 안에서 수행할 코드를 정의하게 된다.
어느곳이나 다 적용가능할거 같지만, 의외로 그리 간단하지는 않다. 텍스트 에디터를 생각한다면, copy-paste 시에 클립보드를 이용해야한다. undo, redo도 지원해야 하므로, 커맨드에 백업용 버퍼가 필요하다. 이를 위해 Memento pattern을 사용해야 한다. 또한, 텍스트 일부가 선택된 상태라면, 새로운 텍스트 입력이나 paste시에 이 선택부분을 백업하고 지워야 한다. 샘플로 구현하려다보니 생각보다 필요한게 많아서 기각.
블렌더나 그래픽 에디터에서 도형의 scale, rotate, move등을 샘플로 해볼까도 생각 했는데, 각각에 따라 다른 파라미터가 필요해서 적합해 보이지 않았다. 이 경우에는 사용자 입력에 따라 Command에 parameter를 입력해 동적으로 생성하고, 입력이 완료될 때, 해당 Command를 execute하는 방식이 되어야 할 것 같다.
Command pattern과 유사한 형태의 디자인은 많아보이지만, 앞에 제시한 UML을 그대로 따르는 가장 적합한 예들은 역시 메뉴나 툴바, 기타 버튼형태의 커맨드가 될듯하다, 뮤직플레이어의 버튼들처럼.
말이 나온김에, 뮤직플레이어를 예로들어볼텐데, play, stop등은 특별한게 없어서 playlist에 대한 일부를 만들어볼까 한다. playlist에선 undo기능까지 해볼 수 있을거 같다. 여기서는 undo를 간단한 스택을 이용하고 Memento pattern까지는 고려하지 않았다.
Kotlin
package command
interface StackImplement<T> {
fun count(): Int
fun pop(): T
fun peek(): T
fun push(item: T)
fun isEmpty(): Boolean
fun isNotEmpty(): Boolean
fun clear(): Unit
}
class Stack<T>: StackImplement<T> {
private var list = mutableListOf<T>()
constructor() {
//super()
}
constructor(initialList: List<T>): this() {
list = initialList.toMutableList()
}
override fun count(): Int {
return list.size
}
override fun pop(): T {
return list.removeAt(list.size - 1)
}
override fun peek(): T {
return list[list.size - 1]
}
override fun push(item: T) {
list.add(item)
}
override fun isEmpty(): Boolean {
return list.size == 0
}
override fun isNotEmpty(): Boolean {
return list.isNotEmpty()
}
override fun clear(): Unit {
list.clear()
}
}
undo 기능을 하려면, command를 stack에 쌓아놔야한다. Kotlin에 stack이 없으므로 따로 구현했다. 특별한건 없으니 바로 다음 코드를 보자.
package command
interface ICommand{
fun execute()
fun undo()
}
Command에 대한 인터페이스 정의이다. Concrete class는 나중에 나온다.
package command
class CommandStack{
private val commandStack = Stack<ICommand>()
fun pushCmd(cmd:ICommand){
commandStack.push(cmd)
}
private fun popCmd(): ICommand?{
return if(commandStack.isNotEmpty())
commandStack.pop()
else
null
}
fun undo(){
popCmd()?.undo()
}
}
command를 저장하는 stack의 구현이다. 그냥 stack이지만, undo가 추가되었다.
package command
class DummyButton(val buttonName: String){
private var command: ICommand? = null
fun assignCommand(cmd: ICommand){
command = cmd
}
fun pressed(){
command?.execute()
}
}
버튼을 가상으로 구현했다. 버튼이 눌렸을 때, 실행되는 내용은 모르고 주어진 ICommand 클래스의 execute()를 호출하여 실행할 내용을 위임한다.
package command
class DummyMusicPlayer{
private val playlist = mutableListOf<String>()
var selectedIdx = 0
fun getPlaylist(): List<String>{
return playlist.toList()
}
fun clearPlaylist(){
playlist.clear()
}
fun append(song: String): Int{
playlist.add(song)
return playlist.count() - 1
}
fun insert(idx: Int, song: String){
playlist.add(idx, song)
}
fun delete(idx: Int = -1): String{
return if(idx == -1)
playlist.removeAt(selectedIdx)
else
playlist.removeAt(idx)
}
}
command 클래스에서 사용할 기능만 일부 구현된 뮤직 플레이어 클래스다. playlist에 대한 일부분만 구현되어 있다.
package command
class ShuffleCommand(private val receiver: DummyMusicPlayer, private val undoStack: CommandStack):ICommand{
private var playlistBackup: List<String> = listOf<String>()
override fun execute() {
playlistBackup = receiver.getPlaylist()
receiver.clearPlaylist()
for(song in playlistBackup.shuffled()){
receiver.append(song)
}
val cmd = ShuffleCommand(receiver, undoStack)
cmd.playlistBackup = playlistBackup.toList()
undoStack.pushCmd(cmd)
}
override fun undo() {
if(playlistBackup.count() > 0){
receiver.clearPlaylist()
for(song in playlistBackup){
receiver.append(song)
}
}
}
}
class AppendCommand(private val receiver: DummyMusicPlayer, private val undoStack: CommandStack):ICommand{
private var idx = -1
override fun execute() {
val song = callFileOpenDialog()
idx = receiver.append(song)
val cmd = AppendCommand(receiver, undoStack)
cmd.idx = idx
undoStack.pushCmd(cmd)
}
override fun undo() {
if(idx != -1){
receiver.delete(idx)
}
}
// only for test. emulate FileOpenDialog
companion object DummyFileOpenDialog{
private val testSongs = arrayOf("song1", "song2", "song3", "song4", "song5")
private var fileOpenIdx = 0
private fun callFileOpenDialog(): String{
// Mockup for file open dialog
val song = testSongs[fileOpenIdx]
fileOpenIdx++
if(fileOpenIdx >= testSongs.count()) fileOpenIdx = 0
return song
}
}
}
class DeleteCommand(private val receiver: DummyMusicPlayer, private val undoStack: CommandStack):ICommand{
private var song: String = ""
private var idx = -1
override fun execute() {
idx = receiver.selectedIdx
song = receiver.delete()
val cmd = DeleteCommand(receiver, undoStack)
cmd.song = song
cmd.idx = idx
undoStack.pushCmd(cmd)
}
override fun undo() {
if(idx != -1){
receiver.insert(idx, song)
}
}
}
단지 3개의 커맨드 shuffle, append, delete 만 구현했는데 길어졌다. 인터페이스에는 execute()외에 undo()가 추가되어 있다. 클라이언트 구현을 보고 자세한 내용을 살펴보자.
package command
fun main(args: Array<String>) {
// make instance of receiver
val musicPlayer = DummyMusicPlayer()
// make commands
val addCmd: ICommand = AppendCommand(musicPlayer)
val delCmd: ICommand = DeleteCommand(musicPlayer)
val shuffleCmd: ICommand = ShuffleCommand(musicPlayer)
// make buttons and assign commands
val addButton = DummyButton("ADD")
addButton.assignCommand(addCmd)
val deleteButton = DummyButton("DELETE")
deleteButton.assignCommand(delCmd)
val shuffleButton = DummyButton("SHUFFLE")
shuffleButton.assignCommand(shuffleCmd)
// test code
addButton.pressed()
addButton.pressed()
addButton.pressed()
addButton.pressed()
addButton.pressed()
println("add test")
println(musicPlayer.getPlaylist())
shuffleButton.pressed()
println("shuffle test")
println(musicPlayer.getPlaylist())
musicPlayer.selectedIdx = 3
deleteButton.pressed()
musicPlayer.selectedIdx = 0
deleteButton.pressed()
println("delete test")
println(musicPlayer.getPlaylist())
println("undo test")
musicPlayer.undo()
println(musicPlayer.getPlaylist())
musicPlayer.undo()
println(musicPlayer.getPlaylist())
musicPlayer.undo()
println(musicPlayer.getPlaylist())
musicPlayer.undo()
println(musicPlayer.getPlaylist())
musicPlayer.undo()
println(musicPlayer.getPlaylist())
}
add test
[song1, song2, song3, song4, song5]
shuffle test
[song5, song4, song2, song1, song3]
delete test
[song4, song2, song3]
undo test
[song5, song4, song2, song3]
[song5, song4, song2, song1, song3]
[song1, song2, song3, song4, song5]
[song1, song2, song3, song4]
[song1, song2, song3]
구현해보면서 고민되는 지점이 몇가지 있었다. 첫번째로 커맨드 수행시, 인자를 넘겨야 하는 경우. 모든 커맨드의 인자가 동일하다면, execute()에 인자를 추가하면 되겠지만, 그렇지 않은경우가 많다. 모든 커맨드가 공통으로 사용할 클래스를 새로 만들어 인자로 넘기는걸 생각해 볼 수 있을것이다. 커맨드마다 필요한 파라미터가 다르다면, 커맨드를 동적으로 생성하며, 파라미터값을 커맨드 생성자에 부여할 수도 있겠다. 여기서는 append시에 추가할 노래정보가 넘어가야 하는데, 별다른 인자없이 fileopen dialog를 띄워 사용자 입력을 받는걸 가정하고 더미로 구현했다.
두번째는 커맨드의 내용을 Command 클래스가 할지, Receiver가 할지이다. 여기서 예를들면, shuffle기능 부분이 어디서 구현해야할지 애매하다. 여기서는 command 클래스에서 구현했다. 이 부분은 첨에 헷갈렸다가 조금 깊게 생각해보니 답이 나왔는데, “playlist 자체가 shuffle 기능이 필요한가?”를 생각하면 된다. playlist가 shuffle기능을 갖게되면, 구현한 command의 undo 기능을 엉망으로 만들 수 있다. 그래서 command 자체에서 shuffle을 구현했다.
세번째는 undo를 위한 command stack 을 어떻게 구현할지 부분이다. 버튼이 눌릴 때, command를 새로 생성하고 있지 않으므로, 그대로 stack에 쌓으면, 데이터가 겹쳐 써져서 제대로 작동하지 않는다. 여기서는 stack 부분이 마지막에 구현되어서 command가 자기자신을 복사하여 stack에 쌓도록 구현했다. 예제에서는 각각 구현이 되었지만, 장기적으로 undo를 생각한다면, command 클래스에 clone 함수 구현을 고려해봐야겠다. 또는, 버튼에서 command 인스턴스를 factory method 방법을 이용하여 생성하는 것도 한가지 선택지가 될 것이다.
또 하나, 첫 구현에서 DummyMusicPlayer안에 undo 기능을 구현했었다. Python 구현시, circular import 문제가 생기고 나서야 잘못된걸 알았는데, Receiver 객체는 Command에 대해 알 필요가 없다. 그래서 undo stack을 빼내어 외부에 만들고, client 코드에서 관리하도록 만들었다.
C#
C#은 유니티에서 테스트 했다. cmdmain.cs만 empty obejct를 생성해서 컴포넌트로 추가하여 실행되도록 했다.
using System;
using System.Collections.Generic;
using System.Linq;
namespace command
{
class DummyMusicPlayer
{
private List<String> playlist = new List<String>();
public int Selected
{
get; set;
}
public DummyMusicPlayer()
{
Selected = 0;
}
public List<String> Playlist
{
get
{
return playlist;
}
set
{
playlist.Clear();
playlist = value;
}
}
public void clearPlaylist()
{
playlist.Clear();
}
public int append(String song)
{
playlist.Add(song);
return playlist.Count() - 1;
}
public void insert(int idx, String song)
{
playlist.Insert(idx, song);
}
public String delete(int idx = -1)
{
String song = null;
if (idx == -1)
{
song = playlist[Selected];
playlist.RemoveAt(Selected);
}
else
{
song = playlist[idx];
playlist.RemoveAt(idx);
}
return song;
}
}
}
앞에서 봤듯이 command와 독립적인 Receiver로 DummyMusicPlayer를 만들었다. Playlist와 Selected를 property로 구현했다.
namespace command
{
interface ICommand
{
void execute();
void undo();
}
}
Command 인터페이스. execute, undo 가 인터페이스로 노출된다.
using System.Collections.Generic;
namespace command
{
class CommandStack
{
private Stack<ICommand> commandStack = new Stack<ICommand>();
public void pushCmd(ICommand cmd)
{
commandStack.Push(cmd);
}
private ICommand popCmd()
{
ICommand cmd = null;
if(commandStack.Count > 0)
{
cmd = commandStack.Pop();
}
return cmd;
}
public void undo()
{
popCmd()?.undo();
}
}
}
undo를 위해 command를 저장할 스택. 스택은 Kotlin에선 없어서 직접 구현했고, C#은 지원하는 자료구조라서 별도 구현이 필요없다. 나중에 보겠지만, Python은 List가 stack에 해당하는 기능도 하기 때문에 이를 이용한다. 언어별 구현 차이점이 흥미로움. 그냥 동일하게 다 지원해주면 좋을거 같은데.
namespace command
{
class DummyButton
{
private string name = null;
private ICommand command = null;
public DummyButton(string buttonName)
{
name = buttonName;
}
public void assignCommand(ICommand cmd)
{
command = cmd;
}
public void pressed()
{
command?.execute();
}
}
}
Invoker 역할의 DummyButton이다. Unity라고 특별히 별도 구현하지 않고 그대로 사용했다. Kotlin과 달리 C#에서는 Access modifier를 public으로 지정해줘야 외부에서 사용가능하다.
using System;
using System.Collections.Generic;
using System.Linq;
namespace command
{
class ShuffleCommand : ICommand
{
private DummyMusicPlayer receiver = null;
private CommandStack undoStack = null;
private List<String> playlistBackup = null;
public ShuffleCommand(DummyMusicPlayer receiver, CommandStack undoStack)
{
this.receiver = receiver;
this.undoStack = undoStack;
}
public void execute()
{
List<String> playlist = receiver.Playlist;
playlistBackup = playlist.ConvertAll(s => s);
playlist.shuffle();
ShuffleCommand cmd = new ShuffleCommand(receiver, undoStack);
cmd.playlistBackup = playlistBackup.ConvertAll(s => s);
undoStack.pushCmd(cmd);
}
public void undo()
{
if(playlistBackup != null)
{
receiver.clearPlaylist();
receiver.Playlist = playlistBackup;
}
}
}
class AppendCommand: ICommand
{
private DummyMusicPlayer receiver = null;
private CommandStack undoStack = null;
private int idx = -1;
public AppendCommand(DummyMusicPlayer receiver, CommandStack undoStack)
{
this.receiver = receiver;
this.undoStack = undoStack;
}
public void execute()
{
String song = DummyFileOpenDialog.callFileOpenDlg();
idx = receiver.append(song);
AppendCommand cmd = new AppendCommand(receiver, undoStack);
cmd.idx = idx;
undoStack.pushCmd(cmd);
}
public void undo()
{
if(idx != -1)
{
receiver.delete(idx);
}
}
}
class DeleteCommand : ICommand
{
private DummyMusicPlayer receiver = null;
private CommandStack undoStack = null;
private String song = "";
private int idx = -1;
public DeleteCommand(DummyMusicPlayer receiver, CommandStack undoStack)
{
this.receiver = receiver;
this.undoStack = undoStack;
}
public void execute()
{
idx = receiver.Selected;
song = receiver.delete();
DeleteCommand cmd = new DeleteCommand(receiver, undoStack);
cmd.song = song;
cmd.idx = idx;
undoStack.pushCmd(cmd);
}
public void undo()
{
if(idx != -1)
{
receiver.insert(idx, song);
}
}
}
static class DummyFileOpenDialog
{
private static List<String> testSongs = new List<String> { "song1", "song2", "song3", "song4", "song5" };
private static int fileOpenIdx = 0;
public static String callFileOpenDlg()
{
String song = testSongs[fileOpenIdx];
fileOpenIdx++;
if (fileOpenIdx >= testSongs.Count()) fileOpenIdx = 0;
return song;
}
}
static class Shuffle
{
private static Random rng = new Random();
public static void shuffle<T>(this IList<T> list)
{
int n = list.Count;
while (n > 1)
{
n--;
int k = rng.Next(n + 1);
T value = list[k];
list[k] = list[n];
list[n] = value;
}
}
}
}
Command 클래스의 구현은 더 길어졌다. shuffle을 지원해주는 Kotlin이나 Python과 달리 직접 구현했다. ( 관련 stackoverflow 참조 )
using System.Collections.Generic;
using UnityEngine;
namespace command
{
public class cmdmain : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
DummyMusicPlayer musicPlayer = new DummyMusicPlayer();
CommandStack undoStack = new CommandStack();
ICommand addCmd = new AppendCommand(musicPlayer, undoStack);
ICommand delCmd = new DeleteCommand(musicPlayer, undoStack);
ICommand shuffleCmd = new ShuffleCommand(musicPlayer, undoStack);
DummyButton addButton = new DummyButton("ADD");
addButton.assignCommand(addCmd);
DummyButton deleteButton = new DummyButton("DELETE");
deleteButton.assignCommand(delCmd);
DummyButton shuffleButton = new DummyButton("SHUFFLE");
shuffleButton.assignCommand(shuffleCmd);
addButton.pressed();
addButton.pressed();
addButton.pressed();
addButton.pressed();
addButton.pressed();
print("add test");
printList(musicPlayer.Playlist);
shuffleButton.pressed();
print("shuffle test");
printList(musicPlayer.Playlist);
musicPlayer.Selected = 3;
deleteButton.pressed();
musicPlayer.Selected = 0;
deleteButton.pressed();
print("delete test");
printList(musicPlayer.Playlist);
print("undo test");
undoStack.undo();
printList(musicPlayer.Playlist);
undoStack.undo();
printList(musicPlayer.Playlist);
undoStack.undo();
printList(musicPlayer.Playlist);
undoStack.undo();
printList(musicPlayer.Playlist);
undoStack.undo();
printList(musicPlayer.Playlist);
}
// Update is called once per frame
void Update()
{
}
void printList<T>(List<T> mylist)
{
string result = "";
foreach(var item in mylist)
{
result += item.ToString() + ", ";
}
print(result);
}
}
}
테스트 클라이언트 클래스. 유니티상이기 때문에 main이 없고, empty obejct 생성해서 위 컴포넌트를 추가해 실행했다. 다른언어와 달리, array나 list를 제대로 출력해주지 않아, 리스트 아이템을 한줄로 출력하는 printList 메소드를 구현했다. 그 결과는 다음과 같다.

잘 동작하는 것을 확인.
Python
파이썬 구현은 다음과 같다. 우선 Receiver에 해당하는 DummyMusicPlayer의 구현이다.
from typing import List
class DummyMusicPlayer:
def __init__(self):
self._playlist: List[str] = []
self._selected_idx = 0
@property
def selected(self):
return self._selected_idx
@selected.setter
def selected(self, value: int):
self._selected_idx = value
@property
def playlist(self):
return self._playlist
@playlist.setter
def playlist(self, new_list: List[str]):
self._playlist = new_list
def clear_playlist(self):
self._playlist.clear()
def append(self, song: str) -> int:
self._playlist.append(song)
return len(self._playlist) - 1
def insert(self, idx: int, song: str):
self._playlist.insert(idx, song)
def delete(self, idx: int = -1) -> str:
song: str = ""
if idx == -1:
song = self._playlist[self.selected]
del self._playlist[self.selected]
else:
song = self._playlist[idx]
del self._playlist[idx]
return song
앞서, Kotlin에서 property를 제대로 사용하지 못한거 같아, property를 적용해 개선했다. Command나 Invoker에 의존성이 없는 독립적인 코드임을 알 수 있다.
class ICommand:
def execute(self):
raise NotImplementedError
def undo(self):
raise NotImplementedError
Command 인터페이스를 별도 파일로 구현했다. 인터페이스만 분리한건, python의 circular import를 피하기 위한 것이기도 하다. 다음에 나올 CommandStack과 각 Command들이 상호참조하기 때문에 서로 임포트 하는경우, circular import 문제가 발생한다. 이를 피하는 편법같은 방법들이 있으나, CommandStack을 참조하지 않는 인터페이스를 분리시키면, 상호참조를 벗어나기 때문에 이게 더 깔끔하다.
from typing import List
from command.ICommand import ICommand
class CommandStack:
def __init__(self):
self._command_stack: List[ICommand] = []
def push_cmd(self, cmd):
self._command_stack.append(cmd)
def _pop_cmd(self) -> ICommand:
if len(self._command_stack) > 0:
return self._command_stack.pop()
else:
return None
def undo(self):
cmd = self._pop_cmd()
cmd.undo()
커맨드 undo를 위해 저장할 stack을 만들었다. Python에선 List가 stack 기능도 지원한다.
import random
from typing import List
from command.ICommand import ICommand
from command.commandstack import CommandStack
from command.dummyplayer import DummyMusicPlayer
class ShuffleCommand(ICommand):
def __init__(self, receiver: DummyMusicPlayer, undo_stack: CommandStack):
self._receiver = receiver
self._undo_stack = undo_stack
self._backup_list: List[str] = None
def execute(self):
# self._backup_list = self._receiver.shuffle()
playlist = self._receiver.playlist
self._backup_list = playlist[:]
random.shuffle(playlist)
cmd = ShuffleCommand(self._receiver, self._undo_stack)
cmd._backup_list = self._backup_list
self._undo_stack.push_cmd(cmd)
def undo(self):
print("shuffle undo")
if self._backup_list is not None:
self._receiver.playlist = self._backup_list
class AppendCommand(ICommand):
def __init__(self, receiver: DummyMusicPlayer, undo_stack: CommandStack):
self._receiver = receiver
self._undo_stack = undo_stack
self._idx = -1
def execute(self):
song = _DummyFileOpenDlg.call_file_open_dlg()
self._idx = self._receiver.append(song)
cmd = AppendCommand(self._receiver, self._undo_stack)
cmd._idx = self._idx
self._undo_stack.push_cmd(cmd)
def undo(self):
print("append undo")
if self._idx != -1:
self._receiver.delete(self._idx)
class DeleteCommand(ICommand):
def __init__(self, receiver: DummyMusicPlayer, undo_stack: CommandStack):
self._receiver = receiver
self._undo_stack = undo_stack
self._song: str = ""
self._idx: int = -1
def execute(self):
self._idx = self._receiver.selected
self._song = self._receiver.delete(self._idx)
cmd = DeleteCommand(self._receiver, self._undo_stack)
cmd._song = self._song
cmd._idx = self._idx
self._undo_stack.push_cmd(cmd)
def undo(self):
if self._idx != -1:
self._receiver.insert(self._idx, self._song)
# only for test. emulate FileOpenDialog
class _DummyFileOpenDlg:
_test_songs = ["song1", "song2", "song3", "song4", "song5"]
_file_open_idx = 0
@classmethod
def call_file_open_dlg(cls) -> str:
song = cls._test_songs[cls._file_open_idx]
cls._file_open_idx += 1
if cls._file_open_idx >= len(cls._test_songs):
cls._file_open_idx = 0
return song
각 커맨드들을 구현했다. 스택에 넣을 커맨드를 처음엔 copy.deepcopy()를 사용했었는데, 이경우 생성자로 넘겨받은 receiver와 stack도 deep copy되어 별도의 인스턴스로 생성된다. 앞에서도 얘기했지만, 커스텀 clone 함수를 작성하는게 가장 깔끔할 것이다. 여기선 안했지만 😀
from command.commands import ICommand
class DummyButton:
def __init__(self, name: str):
self.name: str = name
self._command: ICommand = None
def assign_command(self, cmd: ICommand):
self._command = cmd
def pressed(self):
if self._command is not None:
self._command.execute()
간단한 DummyButton의 구현.
from command.button import DummyButton
from command.commands import AppendCommand, DeleteCommand, ShuffleCommand
from command.commandstack import CommandStack
from command.dummyplayer import DummyMusicPlayer
def main():
music_player = DummyMusicPlayer()
undo_stack = CommandStack()
addCmd = AppendCommand(music_player, undo_stack)
delCmd = DeleteCommand(music_player, undo_stack)
shuffleCmd = ShuffleCommand(music_player, undo_stack)
add_button = DummyButton("ADD")
add_button.assign_command(addCmd)
delete_button = DummyButton("DELETE")
delete_button.assign_command(delCmd)
shuffle_button = DummyButton("SHUFFLE")
shuffle_button.assign_command(shuffleCmd)
add_button.pressed()
add_button.pressed()
add_button.pressed()
add_button.pressed()
add_button.pressed()
print("add test")
print(music_player.playlist)
shuffle_button.pressed()
print("shuffle test")
print(music_player.playlist)
music_player.selected = 3
delete_button.pressed()
music_player.selected = 0
delete_button.pressed()
print("delete test")
print(music_player.playlist)
print("undo test")
undo_stack.undo()
print(music_player.playlist)
undo_stack.undo()
print(music_player.playlist)
undo_stack.undo()
print(music_player.playlist)
undo_stack.undo()
print(music_player.playlist)
undo_stack.undo()
print(music_player.playlist)
if __name__ == "__main__":
main()
테스트 클라이언트 코드는 앞에서와 동일하다. 다음 결과처럼 잘 자동하는걸 알 수 있다.
add test
['song1', 'song2', 'song3', 'song4', 'song5']
shuffle test
['song3', 'song1', 'song5', 'song2', 'song4']
delete test
['song1', 'song5', 'song4']
undo test
['song3', 'song1', 'song5', 'song4']
['song3', 'song1', 'song5', 'song2', 'song4']
shuffle undo
['song1', 'song2', 'song3', 'song4', 'song5']
append undo
['song1', 'song2', 'song3', 'song4']
append undo
['song1', 'song2', 'song3']
추가적으로 생각해볼 문제로 Undo 기능이 있다. undo를 위한 전략은 상황에 따라 여러가지가 가능하다. 가역적인 command의 경우, 커맨드를 반대로 실행하도록 구현하면 매우 가볍게 수행이 가능해진다. 그런데, 항상 가역적인게 아닌게 문제다. 포토샾 필터를 생각해보면, 필터연산은 비가역적인 경우가 많다. 이 경우, 쉽게 생각할 수 있는건 command를 수행하기 전 단계의 백업본을 만들고 undo시 백업본으로 교체하는 방법이다. 이 경우, 커맨드가 늘어날 수록, 백업본이 늘어나 저장공간 또는 메모리 사용에 주의해야 할 것이다. 대부분 undo 단계에 제약을 두는 이유는 이런 이유로 생각된다. 리얼타임 렌더링이 된다면, 필터 파이프라인에서 해당 필터만 제거하는 방법이 가능할 것이다. 이런 경우는 많지 않겠지만, 블렌더의 노드에디터같은 작업이 이런 방식일 것이다.
Command Pattern을 구현해 보는데, 게으르기도 했지만, 시간이 꽤 오래 걸렸다. 추상적으로 처음 접할 때 쉽다고 생각했는데, 직접 구현해보니 생각할 지점들이 상당히 많았던거 같다. 역시, 직접 구현해봐야 보이는게 있네. 3가지 언어로 구현하는건 귀찮고 계속 헷갈리는데, 이 과정이 각 언어의 특징을 확실하게 기억하게 될거라 믿는다.