This is a very exciting chapter. While command line interfaces (CLIs) may not have the cachet of modern graphical user interfaces (GUIs), especially those of phones or tablets, the CLI is still a remarkably useful and effective user interface. This chapter details the design and implementation of the command line interface for pdCalc. By the end of this chapter, we will, for the first time, have a functioning (albeit feature incomplete) calculator, which is a significant milestone in our development.
这是一个非常令人兴奋的章节。虽然命令行界面(CLI)可能没有现代图形用户界面(GUI)的魅力,尤其是手机或平板电脑的界面,但CLI仍然是一个非常有用和有效的用户界面。本章详细介绍了pdCalc的命令行界面的设计和实现。在本章结束时,我们将首次拥有一个可以运行的(尽管功能不完整)计算器,这是我们开发过程中的一个重要里程碑。

5.1 The User Interface Abstraction(用户界面抽象)

While we could design a fully functioning CLI in isolation, we know from our requirements that the feature complete calculator must have both a CLI and a GUI. Therefore, our overall design will be better served by first considering the commonality between these two interfaces and factoring this functionality into a common abstraction. Let’s consider two design alternatives to constructing a user interface abstraction, a top-down approach and a bottom-up approach.
虽然我们可以孤立地设计一个功能齐全的CLI,但从我们的需求中得知,功能齐全的计算器必须同时具有CLI和GUI。因此,首先考虑这两个界面之间的共性,并将这些功能纳入一个共同的抽象中,将更有利于我们的整体设计。让我们考虑两种构建用户界面抽象的设计方案,一种是自上而下的方法,一种是自下而上的方法。

Designing an abstract interface before considering the concrete types is akin to topdown design. In terms of a user interface, you first consider the barest essentials to which any UI must conform and create an abstract interface based on this minimalist concept. Refinement to the interface becomes necessary when the abstract concept misses something required to implement a concrete type.
在考虑具体的类型之前设计一个抽象的界面,类似于自上而下的设计。就用户界面而言,你首先要考虑任何用户界面必须符合的最基本的要求,并在这个最低限度的概念基础上创建一个抽象的界面。当抽象概念遗漏了实现具体类型所需的东西时,就有必要对界面进行完善。

Designing an abstract interface after considering the concrete types is akin to bottom-up design. Again, in terms of a user interface, you first consider the needs of all the concrete types (CLI and GUI, in this case), look for the commonality between all types, and then distill the common features into an abstraction. Refinement to the interface becomes necessary when you add a new concrete type that requires additional features not considered when the abstraction was originally distilled.
在考虑了具体的类型之后再设计一个抽象的界面,就类似于自下而上的设计。同样,就用户界面而言,你首先要考虑所有具体类型(本例中为CLI和GUI)的需求,寻找所有类型之间的共性,然后将这些共性提炼成一个抽象的功能。当你添加了一个新的具体类型,需要额外的功能,而这些功能在最初提炼抽象的时候并没有考虑到,那么对接口的细化就变得很有必要。

Which strategy is better, in general, for creating an abstract interface: top down or bottom up? As is typical, the answer depends on the particular situation, personal comfort, and style. In this particular scenario, we are better served starting from the abstraction and working downward toward the concrete types (the top-down approach). Why? In this instance, the top-down approach is essentially free. The user interface is one of pdCalc’s high-level modules, and we already defined the abstract interface for the UI in Chapter 2 when we performed our initial decomposition. Let’s now turn the abstract module interface into a practical object-oriented design.
一般来说,在创建抽象界面时,哪种策略更好:自上而下还是自下而上?典型的情况是,答案取决于具体的情况、个人的舒适度和风格。在这个特定的场景中,我们最好从抽象开始,然后向下面的具体类型工作(自顶向下的方法)。为什么?在这个例子中,自上而下的方法基本上是免费的。用户界面是pdCalc的高级模块之一,我们在第二章进行初始分解时已经定义了用户界面的抽象接口。现在让我们把抽象的模块接口变成一个实用的面向对象的设计。

5.1.1 The Abstract Interface(抽象接口)

The point of having an abstract interface for the UI is to enable the rest of the program to interact with the user interface without regard to whether the current interface is graphical, command line, or something else entirely. Ideally, we will be able to factor the abstract interface to the minimum number of functions required to use each concrete interface. Any functions sharing an implementation can be defined in the base class, while any functions requiring unique implementations based on the concrete type can be declared as virtual in the abstract base class and defined in the derived classes. The concept is fairly straightforward, but, as usual, the devil is in the details.
为用户界面设置抽象接口的意义在于,使程序的其他部分能够与用户界面互动,而不考虑当前的界面是图形化的、命令行的还是其他完全不同的东西。理想情况下,我们将能够把抽象接口的系数减到使用每个具体接口所需的最小数量的函数。任何共享实现的函数都可以在基类中定义,而任何需要基于具体类型的独特实现的函数都可以在抽象基类中声明为虚拟,并在派生类中定义。这个概念相当直接,但是,像往常一样,魔鬼就在细节中。
Consider the hierarchy depicted in Figure 5-1. Our goal is to design a minimal but complete interface, consistent with the Liskov Substitutability Principle, for pdCalc’s UserInterface class that will work for both the CLI and the GUI. As previously discussed, we already defined a high-level interface for this UI in Chapter 2. Let’s start from this predefined interface and refactor as necessary.
考虑一下图5-1中描述的层次结构。我们的目标是为pdCalc的UserInterface类设计一个最小但完整的界面,与Liskov可替代性原则相一致,该界面将同时适用于CLI和GUI。如前所述,我们已经在第二章中为这个用户界面定义了一个高级接口。让我们从这个预定义的界面开始,并根据需要进行重构。
image.png
Figure 5-1. A minimal interface hierarchy
Figure 5-1. 一个最小的界面层次结构
Referring to Table 2-2 in Chapter 2, you see that the complete interface for the UserInterface class consists of two event handling functions, postMessage() and stackChanged(), and one UserInterface raised event, commandEntered(). Interestingly, the UserInterface class is a publisher, an observer, and an abstract user interface.
参考第2章的表2-2,你可以看到UserInterface类的完整接口包括两个事件处理函数,postMessage()和stackChanged(),以及一个UserInterface引发的事件,commandEntered()。有趣的是,UserInterface类是一个发布者,一个观察者,和一个抽象的用户接口。

The two event handling functions, postMessage() and stackChanged(), are straightforward at the interface level. As we have done with previous observers, we will simply add these two functions to the public interface of the UserInterface class and create proxy observer classes to broker the communication by the publisher and the actual observer. These proxies are discussed in detail in Section 5.1.2.2 below. Concrete user interfaces must handle the implementations for event handling uniquely based on how the individual UI interacts with the user. Hence, postMessage() and stackChanged() must both be pure virtual. Because there is no need for the UserInterface class to interject during event handling, I chose, for simplicity, to forgo the NVI pattern. However, as was discussed in Chapter 4, one could instead use the NVI pattern with trivial forwarding nonvirtual interface functions.
两个事件处理函数,postMessage()和stackChanged(),在接口层面上是很直接的。正如我们对以前的观察者所做的那样,我们将简单地把这两个函数添加到 UserInterface 类的公共接口中,并创建代理观察者类来代理发布者和实际观察者的通信。这些代理将在下面的第 5.1.2.2 节详细讨论。具体的用户界面必须根据个人用户界面与用户的交互方式来独特地处理事件处理的实现。因此,postMessage()和stackChanged()都必须是纯虚拟的。因为没有必要让UserInterface类在事件处理过程中进行干预,为了简单起见,我选择放弃NVI模式。然而,正如第四章中所讨论的,我们可以用琐碎的转发非虚拟接口函数来代替NVI模式。

The UserInterface class’s role as a publisher is slightly more complicated than its role as an observer. As you saw in Chapter 3 when designing the Stack class, the Stack implemented the publisher interface rather than substituted as a publisher. We therefore concluded that inheritance from the Publisher class should be private. For the UserInterface class, the relationship to the Publisher class is similar except the UserInterface class itself is not the publisher. The UserInterface class is an abstract interface for user interfaces in the system and is inheriting from the Publisher class only to enforce the notion that user interfaces must implement the publisher interface themselves. Both the CLI and the GUI classes will need to access public functions from Publisher (for example, to raise events). Thus, the protected mode of inheritance is appropriate in this instance.
UserInterface类作为发布者的角色要比作为观察者的角色稍微复杂一些。正如你在第3章设计Stack类时看到的,Stack实现了发布者接口,而不是作为发布者来替代。因此我们得出结论,从发布者类的继承应该是私有的。对于UserInterface类,与Publisher类的关系是类似的,只是UserInterface类本身不是发布者。UserInterface类是系统中用户界面的一个抽象接口,从Publisher类继承只是为了执行用户界面必须自己实现发布者接口的概念。CLI和GUI类都需要访问Publisher中的公共函数(例如,引发事件)。因此,在这种情况下,受保护的继承模式是合适的。

Further recall from Chapter 3 that in order for the Stack class to implement the publisher interface, once we used private inheritance, we needed to hoist the Publisher class’s attach() and detach() functions into the Stack’s public interface. The same is true here using protected inheritance. The question, however, is should the hoisting occur in the UserInterface class or in its derived classes? To answer this question, we need to ask how particular user interfaces will be used by pdCalc. Clearly, either a CLI or a GUI ‘is-a’ UserInterface. Therefore, concrete user interfaces will publicly inherit from UserInterface and be expected to obey the LSP. Attaching or detaching events to or from a particular user interface must therefore be able to be accomplished without knowing the underlying UI type. Thus, the attach() and detach() functions must be visible as part of UserInterface’s public interface. Interestingly, in a rather unique implementation of the observer pattern, part of the publisher interface is implemented at the UserInterface level, while another part of the publisher interface is implemented at the derived class level.
进一步回顾第三章,为了让Stack类实现发布者接口,一旦我们使用私有继承,我们需要将Publisher类的attach()和detach()函数吊到Stack的公共接口中。在这里使用保护性继承也是如此。然而,问题是,应该在UserInterface类中还是在其派生类中进行提升?为了回答这个问题,我们需要询问pdCalc将如何使用特定的用户界面。很明显,无论是CLI还是GUI,都是一个UserInterface。因此,具体的用户界面将公开继承自UserInterface,并被期望服从LSP。因此,在不知道底层用户界面类型的情况下,将事件附加或分离到一个特定的用户界面必须能够完成。因此,attach()和detach()函数必须作为UserInterface的公共接口的一部分可见。有趣的是,在观察者模式的一个相当独特的实现中,发布者接口的一部分是在UserInterface层实现的,而发布者接口的另一部分是在派生类层实现的。

Combining all of the above points, we can finally define the UserInterface class as shown in Listing 5-1.
综合以上各点,我们最终可以定义UserInterface类,如清单5-1所示。

Listing 5-1. The UserInterface Class
Listing 5-1. UserInterface类

  1. class UserInterface : protected Publisher {
  2. public:
  3. UserInterface();
  4. virtual ~UserInterface();
  5. virtual void postMessage(const string& m) = 0;
  6. virtual void stackChanged() = 0;
  7. using Publisher::attach;
  8. using Publisher::detach;
  9. static const string CommandEntered;
  10. };

The CommandEntered string is the name of the command entered event. It is needed for attaching or detaching this event and can be given any name unique to events in the UserInterface class.
CommandEntered字符串是命令输入事件的名称。它是附加或分离该事件所需要的,并且可以被赋予UserInterface类中事件所特有的任何名称。

For completeness, I show the final user interface hierarchy in Figure 5-2. The class diagram illustrates the relationship between the CLI, the GUI, the abstract UserInterface class, and the publisher interface. Remember that the inheritance between the UserInterface class and the Publisher class is protected, so a UserInterface (or subsequent derived class) cannot be used as a Publisher. As previously stated, however, the intent for the inheritances between the concrete CLI and GUI classes and the abstract UserInterface class are public, allowing an instantiation of either concrete type to be substituted as a UserInterface.
为了完整起见,我在图5-2中展示了最终的用户界面层次结构。该类图说明了CLI、GUI、抽象的UserInterface类和发布者接口之间的关系。请记住,UserInterface类和Publisher类之间的继承关系是受保护的,所以UserInterface(或随后的派生类)不能被用作Publisher。然而,如前所述,具体的CLI和GUI类与抽象的UserInterface类之间的继承关系的意图是公开的,允许任何一种具体类型的实例被替换为UserInterface。
image.png
Figure 5-2. The user interface hierarchy
Figure 5-2. 用户界面的层次结构

5.1.2 User Interface Events(用户界面事件)

Defining the UserInterface class does not complete the interface for the UI. Because the UserInterface class is an event publisher, we must also define the event data class that corresponds to the commandEntered() event. Additionally, defining the UserInterface class finally completes a publisher/observer pair, so we are finally ready to design and implement event proxy classes to broker events between the user interface and both the command dispatcher and the stack.
定义UserInterface类并不能完成用户界面的接口。因为UserInterface类是一个事件发布者,我们还必须定义与commandEntered()事件相对应的事件数据类。此外,定义UserInterface类最终完成了一个发布者/观察者,所以我们终于准备好设计和实现事件代理类,在用户界面和命令调度器以及堆栈之间进行事件代理。

In Chapter 4, you saw that all commands are delivered to the command dispatcher via events. Specifically, the UI raises an event containing a specific command encoded as a string argument, the CommandDispatcher receives this event, and the string argument is passed to the CommandRepository, where a concrete command is retrieved for processing. As far as the command dispatcher is concerned, handling commandEntered() events is the same, irrespective of whether the encoded command string derives from the CLI or the GUI.
在第四章中,你看到所有的命令都是通过事件传递给命令调度器的。具体来说,UI会引发一个事件,其中包含一个编码为字符串参数的特定命令,CommandDispatcher会接收这个事件,字符串参数会被传递给CommandRepository,在那里会检索到一个具体的命令以供处理。就命令调度器而言,处理commandEntered()事件是一样的,无论编码的命令字符串是来自CLI还是GUI。

Likewise, when the Stack class raises a stackChanged() event, the Stack is indifferent to the particular UserInterface that handles this event. We are therefore motivated to treat the issuing of commandEntered() events and the handling of stackChanged() events uniformly at the UserInterface class level in the user interface hierarchy.
同样,当Stack类引发stackChanged()事件时,Stack对处理该事件的特定UserInterface是无所谓的。因此,我们的动机是在用户界面层次结构中的UserInterface类层面上统一处理commandEntered()事件的发出和 stackChanged()事件的处理。

We begin by examining the common infrastructure for raising commandEntered() events. The commandEntered() event is registered for all user interfaces in the constructor of the UserInterface class. Therefore, any derived user interface class can simply raise this event by calling the raise() function defined by the Publisher interface, which, by protected inheritance, is part of any concrete UI’s implementation. The signature of the raise() function requires the name of the event and the event’s data. Because the event’s name is predefined in the UserInterface’s constructor, the only additional functionality required to raise a command entered event is a common CommandData class. Let’s now look at its design.
我们首先检查一下引发commandEntered()事件的通用基础设施。在UserInterface类的构造函数中,所有用户界面都注册了commandEntered()事件。因此,任何派生的用户界面类都可以通过调用由Publisher接口定义的raise()函数来简单地引发这一事件,通过受保护的继承,它是任何具体用户界面的实现的一部分。raise()函数的签名需要事件的名称和事件的数据。因为事件的名称是在UserInterface的构造函数中预定义的,所以提高一个命令输入事件所需的唯一额外功能是一个普通的CommandData类。现在让我们来看看它的设计。

5.1.2.1 Command Data(命令数据)

In Chapter 3, we designed our event system to use push semantics for passing event data. Recall that push semantics simply means that the publisher creates an object containing the necessary information to handle an event and pushes that object to the observers when an event is raised. The event data object must publicly inherit from the abstract EventData class. Observers receive the event data through the abstract interface when the event is raised, and they retrieve the data by downcasting the event data to the appropriate derived class.
在第三章中,我们设计的事件系统使用push语义来传递事件数据。回顾一下,push语义只是意味着发布者创建一个包含处理事件的必要信息的对象,并在事件发生时将该对象推送给观察者。事件数据对象必须公开地继承于抽象的EventData类。观察者在事件发生时通过抽象接口接收事件数据,他们通过下传事件数据到适当的派生类来获取数据。

For command entered events, the event data is trivially a string containing either a number to be entered on the stack or the name of a command to be issued. The CommandData class simply needs to accept a string command on construction, store this command, and provide a function for observers to retrieve the command. The entire implementation is given in Listing 5-2.
对于命令输入的事件,事件数据是一个字符串,包含要在堆栈中输入的数字或要发出的命令的名称。CommandData类只需要在构造上接受一个字符串命令,存储这个命令,并为观察者提供一个函数来检索这个命令。整个实现在清单5-2中给出。

Listing 5-2. The Complete Implementation for the CommandData Class
Listing 5-2. CommandData类的完整实现

  1. class CommandData : public EventData {
  2. public:
  3. CommandData(const string& s)
  4. : command_(s)
  5. {
  6. }
  7. const string& command() const { return command_; }
  8. private:
  9. string command_;
  10. };

While the mechanics for determining how and when to raise a CommandEntered event is somewhat different between the CLI and the GUI, both raise events by ultimately calling Publisher’s raise() function with a CommandData argument encoded with the particular command being issued. That is, for some command string, cmd, the following code raises a commandEntered() event in the CLI, the GUI, or any other user interface that might inherit from UserInterface:
虽然CLI和GUI之间确定如何和何时引发CommandEntered事件的机制有些不同,但两者都是通过最终调用Publisher的raise()函数,用CommandData参数来编码正在发布的特定命令来引发事件。也就是说,对于某个命令字符串cmd,下面的代码会在CLI、GUI或可能继承自UserInterface的任何其他用户界面中引发一个commandEntered()事件。

  1. raise( UserInterface::CommandEntered, make_shared<CommandData>(cmd) );

Now that we can raise UI events, let’s see how they’re handled.
现在我们可以提出UI事件了,让我们看看它们是如何被处理的。

5.1.2.2 User Interface Observers(用户界面观察者)

The goal for this subsection is to construct the mechanics to enable classes to listen to events. Because the abstract user interface is both a source and sink for events, the UI serves as an ideal candidate to demonstrate how publishers and observers interact with each other.
本小节的目标是构建一个机制,使类能够监听事件。因为抽象的用户界面既是事件的来源,也是事件的汇入点,所以用户界面是演示发布者和观察者如何相互作用的理想对象。

In Chapter 3, we saw that observers are classes that register for and listen to events raised by publishers. Thus far, we have encountered both the CommandDispatcher and UserInterface classes that both need to observe events. While it is possible to make the CommandDispatcher or UserInterface an observer directly, I prefer constructing a dedicated observer intermediary between the publisher and the class that needs to observe an event. I have often nebulously referred to this intermediary as a proxy. I am now ready to give a more concrete meaning to this term.
在第三章中,我们看到观察者是注册和监听由发布者引发的事件的类。到目前为止,我们已经遇到了CommandDispatcher和UserInterface两个类,它们都需要观察事件。虽然可以直接让CommandDispatcher或UserInterface成为观察者,但我更喜欢在发布者和需要观察事件的类之间构造一个专门的观察者中介。我经常模糊地把这种中介称为代理。现在我准备为这个术语赋予更具体的含义。

The proxy pattern [6] is a design pattern that uses a class, the proxy, to serve as the interface for something else. The something else, let’s call it the target, is not strictly defined. It could be a network connection, a file, an object in memory, or, as in our case, simply another class. Often, the proxy pattern is used when the underlying target is impossible, inconvenient, or expensive to replicate. The proxy pattern uses a class buffer to allow the system to perceive the target as an object independent of its underlying composition. In our context, we are using the proxy pattern simply to buffer communication between publishers and observers.
代理模式[6]是一种设计模式,它使用一个类,即代理,来作为其他东西的接口。这个别的东西,我们称之为目标,并没有严格的定义。它可以是一个网络连接、一个文件、一个内存中的对象,或者,在我们的例子中,只是另一个类。通常,当底层目标不可能被复制、不方便或昂贵时,就会使用代理模式。代理模式使用类的缓冲区来允许系统将目标视为一个独立于其底层构成的对象。在我们的环境中,我们使用代理模式只是为了缓冲发布者和观察者之间的通信。

Why are we bothering with the proxy pattern here? This strategy has several distinct advantages. First, it increases clarity in the target class’s public interface by replacing a generically named notify() function with a descriptively named event handling function. Second, an otherwise unnecessary inheritance from the Observer class is removed. Eliminating this dependency reduces coupling, increases cohesion, and facilitates reuse of the target in a setting where it is not an observer. Third, using a proxy class eliminates the ambiguity that arises for a target class that needs to listen to multiple events. Without using proxy classes, an observer would be required to disambiguate events in its single notify() function. Using an individual proxy for each event enables each event to call a unique handler function in the target object. The main disadvantage of implementing the observer pattern using a proxy is the slight cost of one extra indirection for handling each event. However, in situations where using the observer pattern is appropriate, the cost of an extra indirection is negligible.
我们为什么要在这里为代理模式而烦恼呢?这个策略有几个明显的优点。首先,通过用一个描述性的事件处理函数来取代一个通用的notify()函数,它提高了目标类的公共接口的清晰度。其次,取消了对观察者类的不必要的继承。消除这种依赖性可以减少耦合,增加内聚力,并有利于在不是观察者的情况下重复使用目标。第三,使用代理类消除了需要监听多个事件的目标类所产生的模糊性。如果不使用代理类,观察者将需要在其单一的 notify() 函数中对事件进行区分。为每个事件使用单独的代理,可以使每个事件在目标对象中调用一个独特的处理函数。使用代理来实现观察者模式的主要缺点是为处理每个事件增加了一个额外的中介,这是一个小小的代价。然而,在适合使用观察者模式的情况下,额外指令的成本是可以忽略不计的。

Using the proxy pattern for implementing the observer pattern leads to the following two classes for handling commandEntered() and stackChanged() events: CommandIssuedObserver and StackUpdatedObserver, respectively. The CommandIssuedObserver mediates between commandEntered() events raised by the UI and observation in the command dispatcher. The StackUpdatedObserver mediates between stackChanged() events raised by the stack and observation in the UI. The implementation for both of these classes is relatively straightforward and very similar. By way of example, let’s examine the implementation for CommandIssuedObserver. See Listing 5-3.
使用代理模式来实现观察者模式,可以得到以下两个处理commandEntered()和stackChanged()事件的类。CommandIssuedObserver和StackUpdatedObserver。CommandIssuedObserver在用户界面引发的commandEntered()事件和命令调度器的观察之间进行调解。StackUpdatedObserver在堆栈引起的stackChanged()事件和用户界面中的观察之间进行调解。这两个类的实现相对简单,而且非常相似。举例来说,让我们看看CommandIssuedObserver的实现。见清单5-3。

Listing 5-3. The Declaration for CommandIssuedObserver
Listing 5-3. CommandIssuedObserver 的声明

  1. class CommandIssuedObserver : public Observer {
  2. public:
  3. CommandIssuedObserver(CommandDispatcher& ce);
  4. void notifyImpl(shared_ptr<EventData>) override;
  5. private:
  6. CommandDispatcher& ce_;
  7. };

Because it mediates events between the UI as a publisher and the CommandDispatcher as the target of the observer, the CommandIssuedObserver’s constructor takes a reference to a CommandDispatcher instance, which it retains to call back to the command dispatcher when the UI raises a commandEntered() event. Recall that the CommandIssuedObserver will be stored by the UI in the Publisher’s event symbol table when the observer is attached to the event. The implementation of notifyImpl() is simply a dynamic cast of the EventData parameter to a specific CommandData instance followed by a call to CommandDispatcher’s commandEntered() function.
因为它在作为发布者的 UI 和作为观察者目标的 CommandDispatcher 之间调解事件,CommandIssuedObserver 的构造函数需要一个对 CommandDispatcher 实例的引用,当 UI 引发 commandEntered() 事件时,它保留这个引用以回调到命令调度器。回想一下,CommandIssuedObserver将被UI存储在Publisher的事件符号表中,当观察者被附加到事件时。notifyImpl()的实现只是将EventData参数动态地投到一个特定的CommandData实例,然后调用CommandDispatcher的commandEntered()函数。

Of course, before an event is triggered, the CommandIssuedObserver must be registered with the UI. For completeness, the following code illustrates how to accomplish this task:
当然,在事件被触发之前,CommandIssuedObserver必须被注册到用户界面。为了完整起见,下面的代码说明了如何完成这项任务。

  1. ui.attach( UserInterface::CommandEntered, make_unique<CommandIssuedObserver>(ce) );

where ui is a UserInterface reference. Note that since the attach() function was intentionally hoisted into the abstract UserInterface scope, attaching through a reference allows us to reuse the same call for both the CLI and the GUI. That is, registering events is accomplished through the abstract UI interface, which greatly simplifies user interface setup in pdCalc’s main() routine. The declaration and registration of StackUpdatedObserver is analogous.
其中ui是一个UserInterface引用。请注意,由于attach()函数被故意吊在抽象的UserInterface范围内,通过引用附加允许我们在CLI和GUI中重复使用同一个调用。也就是说,注册事件是通过抽象的UI界面完成的,这大大简化了pdCalc的main()例程中的用户界面设置。StackUpdatedObserver的声明和注册是类似的。

The complete implementation of the observer proxy classes can be found in AppObservers.cpp. While the usage of the observer proxies is intertwined with the event-observing classes, the proxies are not part of the interface for the target classes. Hence, they are included in their own file. The attachment of the proxies to events is performed in main.cpp. This code structure preserves the loose binding between publishers and observers. Specifically, publishers know which events they can raise, but not who will observe them, while observers know which events they will watch, but not who raises them. Code external to both publishers and their observers binds the two together.
观察者代理类的完整实现可以在AppObservers.cpp中找到。虽然观察者代理的使用与事件观察类交织在一起,但代理并不是目标类的接口的一部分。因此,它们被包含在自己的文件中。在main.cpp中进行代理与事件的连接。这种代码结构保留了发布者和观察者之间的松散联系。具体来说,发布者知道他们可以提出哪些事件,但不知道谁将观察这些事件,而观察者知道他们将观察哪些事件,但不知道谁提出了这些事件。发布者及其观察者的外部代码将两者绑定在一起。

5.2 The Concrete CLI Class(具体的CLI类)

The remainder of this chapter is devoted to detailing the CLI concrete class. Let’s start by re-examining the CLI’s requirements.
本章的其余部分将用于详细介绍CLI的具体类。让我们首先重新审视一下CLI的要求。

5.2.1 Requirements(要求)

The requirements for pdCalc indicate that the calculator must have a command line interface, but what, precisely, is a CLI? My definition for a command line interface is any user interface to a program that responds to user commands interactively through text. Even if your definition for a command line interface is somewhat different, I believe we can certainly agree that a broad requirement simply indicating a program should have a CLI is woefully insufficient.
pdCalc的要求表明,计算器必须有一个命令行界面,但准确地说,什么是CLI?我对命令行界面的定义是任何通过文本对用户命令做出交互式响应的程序的用户界面。即使你对命令行界面的定义有些不同,我相信我们肯定会同意,一个宽泛的要求仅仅表明一个程序应该有一个CLI是非常不充分的。

In a production development situation, when you encounter a requirement too vague to design a component, you should immediately seek clarification from your client. Notice I said when and not if. Regardless of how much effort you place upfront trying to refine requirements, you always have incomplete, inconsistent, or changing requirements. This usually arises for a few reasons. Sometimes, it is due to a conscious effort not to spend the upfront time refining requirements. Sometimes, it arises from an inexperienced team member not understanding how to gather requirements properly. Often, however, it simply arises because the end user doesn’t know what he or she truly wants or needs until the product starts to take shape. I find this true even for small development projects for which I am my own customer! While you as the implementer always retain the expedient option of refining a requirement without engaging your customer, my experience indicates that this path invariably leads to rewriting the code repeatedly: once for what you thought the user wanted, once for what the user thought he wanted, and once for what the user actually wanted.
在生产开发的情况下,当你遇到一个过于模糊的需求来设计一个组件时,你应该立即向你的客户寻求澄清。注意我说的是 “当 “而不是 “如果”。无论你在前期做了多少努力来完善需求,你总是有不完整的、不一致的或变化的需求。这通常是由于几个原因引起的。有时,它是由于有意识地不花前期时间来完善需求。有时,它是由于没有经验的团队成员不了解如何正确地收集需求而产生的。然而,通常情况下,这只是因为终端用户在产品开始形成之前不知道他或她真正想要什么或需要什么。我发现即使是我自己的客户的小型开发项目也是如此。虽然作为实施者,你总是保留着一个权宜之计,即在不与客户接触的情况下完善需求,但我的经验表明,这条道路总是会导致反复重写代码:一次是你认为用户想要的,一次是用户认为他想要的,还有一次是用户实际想要的。

Obviously, for our case study, we only have a hypothetical end user, so we’ll simply do the refinement ourselves. We specify the following:

  1. The CLI should accept a text command for any command defined for the calculator (those that exists in the command repository plus undo, redo, help, and exit).
  2. The help command should display a list of all available commands and a short explanatory message.
  3. The CLI should accept space-separated commands in the order in which they should be processed. Recall that this order corresponds to Reverse Polish Notation. All commands on a line are processed after return is pressed.
  4. After commands are processed, the interface should display at most the top four elements of the stack plus the stack’s current size.

显然,在我们的案例研究中,我们只有一个假想的最终用户,所以我们将简单地自己做细化工作。我们指定如下:

  1. CLI应该接受为计算器定义的任何命令的文本命令(那些存在于命令库中的命令加上撤销、重做、帮助和退出)。
  2. 帮助命令应显示所有可用命令的列表和简短的解释信息。
  3. CLI应该按照处理命令的顺序接受以空格分隔的命令。回顾一下,这个顺序对应于反向波兰语符号。一行的所有命令在按下回车键后被处理。
  4. 在命令被处理后,界面应该最多显示Stack的前四个元素,加上Stack的当前大小。

Surprisingly, the minimal requirements listed above are sufficient to build a simple CLI. While these requirements are somewhat arbitrary, something specific needed to be chosen in order to describe a design and implementation. If you don’t like the resultant CLI, I highly encourage you to specify your own requirements and modify the design and implementation accordingly.
令人惊讶的是,上面列出的最低要求足以建立一个简单的CLI。虽然这些要求有些随意,但为了描述一个设计和实现,需要选择一些具体的东西。如果你不喜欢由此产生的CLI,我非常鼓励你指定你自己的要求,并相应地修改设计和实现。

5.2.2 The CLI Design(CLI设计)

The design of the CLI is remarkably simple. Because our overall architectural design placed the entire ‘business logic’ of the calculator in the back end, the front end is merely a thin layer that does nothing more than accept and tokenize input from the user, pass that input to the controller sequentially, and display the results. Let’s begin by describing the interface.
CLI的设计是非常简单的。因为我们的整体架构设计将计算器的整个 “业务逻辑 “放在了后端,前端只是一个薄薄的层,只是接受和标记来自用户的输入,将这些输入依次传递给控制器,并显示结果。让我们从描述界面开始。

5.2.2.1 The Interface(接口)

From the analysis we performed earlier in the chapter, we know that the concrete CLI class will inherit from the abstract UserInterface class. This inheritance is public because the CLI ‘is a’ UserInterface and must substitute as one. Hence, the CLI must implement the UserInterface’s two abstract pure virtual functions, postMessage() and stackChanged(). These two methods are only called polymorphically through a UserInterface reference; therefore, both methods become part of the private interface of the CLI. Other than construction and destruction, the only functionality that the CLI needs to expose publicly is a command that starts its execution. This function drives the entire CLI and only returns (normally) when the user requests to quit the program. Combining the above, the entire interface for the CLI can be given by the code in Listing 5-4.
从我们在本章前面进行的分析中,我们知道具体的CLI类将继承于抽象的UserInterface类。这种继承是公开的,因为CLI “是一个 ”UserInterface,并且必须作为一个来替代。因此,CLI必须实现UserInterface的两个抽象的纯虚拟函数,postMessage()和stackChanged()。这两个方法只有通过UserInterface引用才能被多态地调用;因此,这两个方法成为CLI的私有接口的一部分。除了构造和销毁之外,CLI需要公开的唯一功能是一个开始执行的命令。这个功能驱动整个CLI,并且只有在用户请求退出程序时才会返回(通常)。结合上述内容,CLI的整个界面可以由清单5-4的代码给出。

Listing 5-4. The Entire Interface for the CLI
Listing 5-4. CLI的整个界面

  1. class Cli : public UserInterface {
  2. class CliImpl;
  3. public:
  4. Cli(istream& in, ostream& out);
  5. ~Cli();
  6. void execute(bool suppressStartupMessage = false, bool echo = false);
  7. private:
  8. void postMessage(const string& m) override;
  9. void stackChanged() override;
  10. unique_ptr<CliImpl> pimpl_;
  11. };

While the interface is mostly self-explanatory, the arguments to both the constructor and the execute() function are worth explaining. To meet the requirements described above, the execute() function could be written with no arguments. The two arguments included in the interface are simply optional features that can be turned on. The first argument dictates whether or not a banner is displayed when the CLI starts. The second argument controls command echoing. If echo is set to true, then each command is repeated before displaying the result. Both of these features could be hard coded in the CLI, but I chose to add them as arguments to the execute() method for added flexibility.
虽然接口大多是不言自明的,但构造函数和execute()函数的参数都值得解释一下。为了满足上述要求,execute()函数可以不写参数。接口中包含的两个参数只是可以开启的可选功能。第一个参数决定了CLI启动时是否会显示横幅。第二个参数控制命令的回声。如果echo被设置为 “true”,那么每条命令在显示结果之前都会被重复。这两个功能都可以在CLI中硬编码,但我选择将它们作为参数添加到execute()方法中,以增加灵活性。

The arguments to the constructor are slightly less obvious than the arguments to the execute() command. Almost by definition, a CLI takes input from cin and outputs results to cout or maybe cerr. However, hard coding these standard I/O streams arbitrarily limits the usage of this class to that of a traditional CLI. Usually, I advocate limiting functionality to exactly what you need instead of anticipating more general usage. However, using C++ stream I/O is one of my few exceptions to my rule of thumb.
构造函数的参数比execute()命令的参数稍显逊色。几乎根据定义,CLI从cin获取输入,并将结果输出到cout或者cerr。然而,对这些标准的I/O流进行硬编码,任意地将这个类的用法限制为传统的CLI。通常情况下,我主张将功能限制在你所需要的范围内,而不是预想的更普遍的用途。然而,使用C++流I/O是我的经验法则的少数例外之一。

Let’s discuss why using references to base class C++ I/O streams is generally a good design practice. First, the desire to use different I/O modes is quite common. Specifically, redirection to or from files is a frequently requested modification to a CLI. In fact, you’ll see this request in Chapter 8! Second, implementing the generic versus specific interface adds virtually no complexity. For example, instead of directly writing to cout, one simply keeps a reference to an output stream and writes to that instead. In the base case, this reference simply points to cout. Finally, using arbitrary stream input and output greatly simplifies testing. While the program may instantiate the Cli class using cin and cout, tests can instantiate the Cli class with either a file stream or a string stream. In this manner, interactive stream inputs and outputs can be simulated using strings or files. This strategy simplifies testing of the Cli class since inputs can be easily passed in and outputs easily captured as strings rather than through standard input and output.
让我们讨论一下为什么使用对基类C++ I/O流的引用通常是一种良好的设计实践。首先,使用不同的I/O模式的愿望是非常普遍的。具体来说,重定向到文件或从文件重定向是对CLI经常要求的修改。事实上,你会在第8章看到这种要求。第二,实现通用与特定接口几乎没有增加任何复杂性。例如,不直接写到cout,而只是保留一个对输出流的引用,并写到该输出流。在基本情况下,这个引用只是指向cout。最后,使用任意的流输入和输出大大简化了测试。虽然程序可以使用cin和cout来实例化Cli类,但测试可以用文件流或字符串流来实例化Cli类。通过这种方式,可以用字符串或文件来模拟交互式流输入和输出。这种策略简化了Cli类的测试,因为输入可以很容易地传入,输出可以很容易地捕获为字符串而不是通过标准输入和输出。

5.2.2.2 The Implementation(实现)

The implementation of the Cli class is worth examining to observe the simplicity enabled by the modularity of pdCalc’s design. The entire implementation of the Cli class is effectively contained in the execute() and postMessage() member functions. The execute() function drives the CLI. It presents a startup message to the end user, waits for commands to be entered, tokenizes these commands, and raises events to signal to the command dispatcher that a new command has been entered. The stackChanged() function is an observer proxy callback target that writes the top of the stack to the command line after the stackChanged() event is raised. Essentially, the CLI reduces to two I/O routines where execute() handles input and stackChanged() handles output. Let’s look at the implementations for these two functions starting with the execute() function shown in Listing 5-5.
Cli类的实现值得研究,以观察pdCalc的模块化设计所带来的简单性。Cli类的全部实现都有效地包含在execute()和postMessage()成员函数中。execute()函数驱动CLI。它向终端用户展示一个启动信息,等待命令的输入,对这些命令进行标记,并引发事件,向命令调度器发出新命令被输入的信号。stackChanged()函数是一个观察者代理回调目标,在stackChanged()事件被引发后,将栈顶写入命令行。本质上,CLI简化为两个I/O例程,execute()处理输入,stackChanged()处理输出。让我们看看这两个函数的实现,首先是清单5-5中所示的execute()函数。

Listing 5-5.The execute() Function

  1. void Cli::CliImpl::execute(bool suppressStartupMessage, bool echo)
  2. {
  3. if (!suppressStartupMessage)
  4. startupMessage();
  5. for (string line; getline(in_, line, '\n');) {
  6. Tokenizer tokenizer { line };
  7. for (const auto& i : tokenizer) {
  8. if (echo)
  9. out_ << i << endl;
  10. if (i == "exit" || i == "quit")
  11. return;
  12. else
  13. parent_.raise(CommandEntered, make_shared<CommandData>(i));
  14. }
  15. }
  16. return;
  17. }

The main algorithm for the CLI is fairly simple. First, the CLI waits for the user to input a line. Second, this input line is tokenized by the Tokenizer class. The CLI then loops over each token in the input line and raises an event with the token string as the event’s data. The CLI terminates when it encounters either a quit or an exit token.
CLI的主要算法相当简单。首先,CLI等待用户输入一行。其次,这个输入行被Tokenizer类标记化。然后,CLI在输入行的每个标记上循环,并引发一个以标记字符串为事件数据的事件。当CLI遇到退出或退出标记时,它就会终止。

The only piece of the execute() function not previously explained is the Tokenizer class. Simply, the Tokenizer class is responsible for taking a string of text and splitting this string into individual space-separated tokens. Neither the CLI nor the Tokenizer determines the validity of tokens. Tokens are simply raised as events for the command dispatcher to process. Note that as an alternative to writing your own, many libraries (boost, for example) provide simple tokenizers.
execute()函数中唯一没有解释过的部分是Tokenizer类。简单地说,Tokenizer类负责接收一串文本,并将这串文本分割成以空格分隔的单个tokens。无论是CLI还是Tokenizer,都不能确定令牌的有效性。令牌只是作为事件被提出,供命令调度器处理。注意,作为自己编写的替代方案,许多库(例如boost)提供了简单的标记器。

Given the simplicity of the tokenization algorithm (see the implementation in Tokenizer.cpp), why did I design the Tokenizer as a class instead of as a function returning a vector of strings? Realistically, both designs functionally work, and both designs are equally easy to test and maintain. However, I prefer the class design because it provides a distinct type for the Tokenizer. Let’s examine the advantages of creating a distinct type for tokenization.
鉴于标记化算法的简单性(见Tokenizer.cpp中的实现),为什么我把标记化器设计成一个类而不是一个返回字符串向量的函数?实际上,两种设计在功能上都是可行的,而且两种设计都同样易于测试和维护。然而,我更喜欢类的设计,因为它为Tokenizer提供了一个独特的类型。让我们来看看为标记化创建一个独特类型的优势。

Suppose we wanted to tokenize input in function foo() but process tokens in a separate function, bar(). Consider the following two possible pairs of functions to achieve this goal:
假设我们想在函数foo()中对输入进行标记,但在另一个函数bar()中处理标记。请考虑以下两个可能的函数对来实现这一目标:

  1. // use a Tokenizer class
  2. Tokenizer foo(const string&);
  3. void bar(const Tokenizer&);
  4. // use a vector of strings
  5. vector<string> foo(const string&);
  6. void bar(const vector<string>&);

First, using a Tokenizer class, the signatures for both foo() and bar() immediately inform the programmer the intent of the functions. We know these functions involve tokenization. Using a vector of strings leaves ambiguity without further documentation (I intentionally did not provide names for the arguments). More importantly, however, typing the tokenizer enables the compiler to ensure that bar() can only be called with a Tokenizer class as an argument, thus preventing a programmer from accidentally calling bar() with an unrelated collection of strings. Another benefit of the class design is that a Tokenizer class encapsulates the data structure that represents a collection of tokens. This encapsulation shields the interface to bar() from a decision to change the underlying data structure from, for example, a vector of strings to a list of strings. Finally, a Tokenizer class can encapsulate additional state information about tokenization (e.g., the original, pre-tokenized input), if desired. A collection of strings is obviously limited to carrying only the tokens themselves.
首先,使用Tokenizer类,foo()和bar()的签名立即告知程序员这些函数的意图。我们知道这些函数涉及标记化。使用一个字符串的向量,在没有进一步文档的情况下,会产生歧义(我故意不提供参数的名字)。然而,更重要的是,标记化器的类型化使编译器能够确保bar()只能以标记化器类作为参数被调用,从而防止程序员意外地用不相关的字符串集合调用bar()。该类设计的另一个好处是,Tokenizer类封装了代表标记集合的数据结构。这种封装屏蔽了bar()的接口,使其不受改变底层数据结构的影响,例如,从一个字符串的向量到一个字符串的列表。最后,如果需要的话,一个标记化器类可以封装关于标记化的额外状态信息(例如,原始的、预先标记过的输入)。一个字符串的集合显然只限于携带标记本身。

Now let’s examine a simplified implementation of the stackChanged() function, shown in Listing 5-6.
现在让我们来看看 stackChanged() 函数的简化实现,如清单 5-6 所示。

Listing 5-6. The stackChanged Function

  1. void Cli::CliImpl::stackChanged()
  2. {
  3. unsigned int nElements { 4 };
  4. auto v = Stack::Instance().getElements(nElements);
  5. ostringstream oss;
  6. size_t size = Stack::Instance().size();
  7. oss << "stack\n";
  8. size_t j { v.size() };
  9. for (auto i = v.rbegin(); i != v.rend(); ++i) {
  10. oss << j << ":\t" << *i << "\n";
  11. --j;
  12. }
  13. postMessage(oss.str());
  14. }

The implementation in Cli.cpp differs only in the fanciness of the printing. Note that whenever the stack changes, the CLI simply picks the top four (as specified in our requirements) entries of the stack (getElements() returns the minimum of nElements and the size of the stack), formats them in an ostringstream, and passes a string message to the postMessage() function. For the CLI, postMessage() simply writes the string to the output stream.
Cli.cpp中的实现只在打印的花哨程度上有所不同。请注意,每当Stack发生变化时,CLI只是挑选Stack的前四个(按照我们的要求指定)条目(getElements()返回nElements的最小值和Stack的大小),将它们格式化为ostringstream,并将一个字符串信息传递给postMessage()函数。对于CLI来说,postMessage()只是把字符串写到输出流中。

Before we move on, let’s pause and reflect on how clean and brief the implementation for the CLI is. This simplicity is a direct result of pdCalc’s overall design. Whereas many user interfaces intermix the business logic with the display code, we meticulously designed these two layers to be independent. Interpretation and processing of commands (the business logic) resides entirely in the command dispatcher. Therefore, the CLI is only responsible for accepting commands, tokenizing commands, and reporting results. Furthermore, based on the design of our event system, the CLI has no direct coupling to the command dispatcher, a decision consistent with our MVC architecture. The command dispatcher does have a direct link to the user interface, but because of our abstraction, the command dispatcher binds to an abstract UserInterface rather than a specific user interface implementation. In this way, the Cli perfectly substitutes as a UserInterface (application of the LSP) and can trivially be swapped in or out as any one of many unique views to the calculator. While this flexibility may seem like overkill for the design of a calculator, the modularity of all of the components is beneficial from both a testing and separation of concerns standpoint even if the calculator were not slated to have another user interface.
在我们继续之前,让我们暂停一下,反思一下CLI的实现是多么的简洁和简单。这种简单性是pdCalc整体设计的直接结果。许多用户界面将业务逻辑和显示代码混合在一起,而我们精心设计的这两层是独立的。命令的解释和处理(业务逻辑)完全在命令调度器中。因此,CLI只负责接受命令,对命令进行标记,并报告结果。此外,根据我们事件系统的设计,CLI与命令调度器没有直接的耦合,这个决定与我们的MVC架构一致。命令调度器确实与用户界面有直接的联系,但由于我们的抽象,命令调度器绑定到一个抽象的UserInterface,而不是一个具体的用户界面实现。通过这种方式,Cli完美地替代了UserInterface(LSP的应用),并且可以很容易地被替换成计算器的许多独特视图中的任何一个。虽然这种灵活性对于计算器的设计来说似乎是多余的,但从测试和关注点分离的角度来看,所有组件的模块化是有益的,即使计算器不打算有其他用户界面。

5.3 Tying It Together: A Working Program

Before we conclude this chapter on the CLI, it is worthwhile to write a simple main program that ties all of the components together to demonstrate a working calculator. pdCalc’s actual implementation in main.cpp is significantly more complicated because it handles multiple user interfaces and plugins. Eventually, we will build up to understanding the full implementation in main.cpp, but for now, the code in Listing 5-7 will enable us to execute a working calculator with a command line interface (of course, including the appropriate header files):
在我们结束这一章的 CLI 章节之前,写一个简单的主程序是值得的,它将所有的组件联系在一起,演示了一个工作的计算器。pdCalc 在 main.cpp 中的实际实现要复杂得多,因为它要处理多个用户界面和插件。最终,我们将逐步了解main.cpp中的完整实现,但现在,清单5-7中的代码将使我们能够通过命令行界面执行一个工作的计算器(当然,包括适当的头文件)。

Listing 5-7. A Working Calculator

  1. int main()
  2. {
  3. Cli cli { cin, cout };
  4. CommandDispatcher ce { cli };
  5. RegisterCoreCommands(cli);
  6. cli.attach(UserInterface::CommandEntered,
  7. make_unique<CommandIssuedObserver>(ce));
  8. Stack::Instance().attach(Stack::StackChanged,
  9. make_unique<StackUpdatedObserver>(cli));
  10. cli.execute();
  11. return 0;
  12. }

Due to the modularity of the design, the entire calculator can be set up, assembled, and executed in just six executable statements! The logic within the main() function is easy to follow. From a maintenance perspective, any new programmer to the project would easily be able trace the calculator’s logic and see that the functionality for each module is clearly divided into distinct abstractions. As will be seen in future chapters, the abstraction is even more powerful as more modules are added.
由于设计的模块化,整个计算器的设置、组装和执行只需六条可执行的语句就可以了! main()函数中的逻辑是很容易理解的。从维护的角度来看,任何新的程序员都可以很容易地追踪到计算器的逻辑,并看到每个模块的功能被清楚地划分为不同的抽象概念。正如在未来的章节中所看到的,随着更多模块的加入,这种抽象甚至更加强大。

To get you started quickly, a project is included in the repository source code that builds an executable, pdCalc-simple-cli, using the above main() function as the application’s driver. The executable is a standalone CLI that includes all of the features discussed up to this point in the book.
为了让你快速入门,在资源库的源代码中包含了一个项目,它使用上面的main()函数作为应用程序的驱动,构建了一个可执行文件,pdCalc-simple-cli。该可执行文件是一个独立的CLI,包括本书到此为止讨论的所有功能。

In the next chapter, we’ll consider the design of the graphical user interface for our calculator. As soon as the GUI is complete, many users will quickly dismiss the CLI as simply an exercise or a relic from a previous era. Before doing so, I’d like to encourage the reader not to be so quick to judge the humble CLI. CLIs are very efficient interfaces, and they are typically much easier to script for tasks requiring large deployments or automation. As for pdCalc, personally, I prefer the CLI to the GUI due to its ease of use. Of course, maybe that is just an indication that I, too, am a relic from a previous era.
在下一章中,我们将考虑为我们的计算器设计图形化的用户界面。图形用户界面一完成,许多用户就会很快否定CLI,认为它只是一种练习或上个时代的遗物。在这之前,我想鼓励读者不要这么快就评判简陋的CLI。CLI是非常有效的界面,对于需要大规模部署或自动化的任务来说,它们通常更容易编写脚本。至于pdCalc,我个人更喜欢CLI而不是GUI,因为它易于使用。当然,也许这只是表明我也是一个来自上一个时代的遗迹。