现实世界中的 OOP - 创建方程编辑器






4.87/5 (103投票s)
使用真实世界示例进行面向对象的设计和编程过程
更新于2019年7月11日:已添加最新安装程序。
Math Editor(完整版)是免费且开源的。您可以从https://github.com/kashifimran/math-editor获取完整源代码。在本文中,我将使用一个精简版,我们称之为Math Editor Mini,以作教学目的。
引言
方程或公式编辑器是一种计算机软件,可以帮助我们排版数学内容。在本文中,我将尝试为读者提供面向对象设计和编程技术的实际应用,通过构建我们的方程编辑器。
使用的语言是C#,我们将使用WPF作为GUI框架。但是,提供的技术不依赖于编程语言或GUI平台,可以应用于Java等任何其他面向对象语言。
在我继续之前,我想对STIX字体项目表示感谢,感谢他们创建了如此出色的免费数学和科学排版字体。
这是Math Editor的截图

背景
互联网上以及书籍中有大量的资源教授基本原理,如对象、继承、多态、封装等等。我的重点将不是术语,因为我假设本文的读者已经熟悉了该主题的基础知识,并且在C#或Java等面向对象语言中有一些编程经验。
在本文中,我主要打算针对以下两类读者
- 了解面向对象基础但无法将技术应用于实际问题的程序员
- 已经知道所有技巧但想构建方程编辑器并希望有一个起点的情商程序员
面向对象设计过程分析
学习面向对象编程的最佳方法是在实际程序中应用这些技术。仅仅完成教程和书籍中的几个示例通常是不够的,因为那里呈现的示例通常非常肤浅。面向对象设计和编程中最大的挑战是识别对象和继承以及将职责委托给不同的类。在教科书中典型的例子中,对象几乎是清晰可见的,几乎不需要做什么来定义它们的角色和职责。上图是这种情况的一个典型例子

正如我们所见,上图所示的关系链相当自然,我们几乎不需要付出任何努力来定义它。然而,在现实世界的问题中,我们并不总是如此幸运能够拥有这种自然的继承层次结构。例如,想想GDI+或WPF。这些平台中的继承链真的代表了一个自然的底层系统吗?它们是凭空产生的,还是您认为它们是如何构建起来的,其中花费了一些努力?我相信答案对您来说显而易见!
有时界限如此模糊,以至于我们很难弄清楚如何设计一个方便的面向对象模型。即使是最好的专家有时也会感到困惑(问问MFC的人!)。在这种情况下,唯一的好策略是给自己一些时间,继续寻找看不见的对象以及它们之间的链接。您甚至可能需要尝试几种不同的模型,然后才能决定选择哪一个!
为方程编辑器设计面向对象模型
现在是时候转向我们决定要解决的具体案例,看看我们是否能找到我们的对象及其职责和关系。让我们先仔细看看Math Editor排版的一些方程类型

现在尝试回答以下问题
- 您是否看到了可用于方程编辑器面向对象模型的任何对象?
- 我们看到的线条、字母和括号将按原样成为我们的对象,还是我们需要一些其他更高级别或更低级别的对象,这些对象与这些图形和字母不同但又能够表示它们?
- 这些对象之间是否存在特定关系?如果是,这种关系是否适合在面向对象模型中表示?
- 是否存在可以放入定义良好的相关类中的共同特征?
- 是否有可能创建一个通用框架来处理不同类型方程的输入和输出?还是我们需要在不同组件中使用不同的策略?
我们的目标不仅是识别仅当前可见的方程实体、关系和角色。我们希望具有前瞻性。我们希望创建一个健壮的模型,该模型自然、灵活、可扩展、可维护、易于理解且不太复杂。
我们的任务清单包括以下内容
- 识别一组能够表示所有当前支持以及尚未支持的方程的类。
- 构建一个通用模型,使我们能够以统一的方式排版、序列化和表示方程。
- 创建用于处理鼠标和键盘等输入的统一框架。
任务清单看起来很短。然而,如果您仔细考虑,您会发现它既苛刻又包罗万象。第一个任务不仅要求我们处理示例中看到的少数方程,还要求我们为尚未考虑的更多类型的方程创建基本支持。第二个任务要求以一种相对容易的方式将方程转换为其他表示形式(例如,MathML或TeX),以便在需要时进行转换。第三个任务要求创建一个输入机制,以满足我们将支持的所有类型方程的基本需求。
现在我们已经确定了一些最重要的任务和目标,让我们再次看看上面看到的几个方程,并尝试找到我们提出的问题的答案。您看到模式了吗?您能回答其中一些或任何一个问题吗?如果是,您做得很好。但很可能您在几分钟内不会发现太多东西!
请记住,当概念比具体更抽象时,创建一个好的面向对象模型就相对困难,并且几乎总是可以讨论的。从来没有数学证明表明特定的面向对象模型完全适合给定的情况。它更像是一门艺术,而不是一门科学。您可能需要一遍又一遍地重新开始,有时甚至需要等待神圣的启示!我唯一的建议是继续寻找最适合的,并在进行足够思考后做出决定。
我现在将尝试帮助您找到我们正在寻找的答案。这是一张图,我在其中将一些方程表示为我所见的较低级别,已准备好在面向对象的方式下实现它们

从上图可以看出,一种模式正在显现。以下是我们注意到的一些有趣事实
- 其中一些实体是循环的,即它们可以包含自己的相对较小的形式作为容器。例如,我们可以看到一个除法出现在另一个除法内部,一个括号出现在另一个括号内部,嵌套级别没有严格限制。
- 方程以重复的方式在垂直和水平方向上排列。
- Unicode文本要么出现在某个其他容器内,要么出现在顶级容器内。
理解代码
整个核心功能驻留在仅6个类中。您只需要对这6个核心类有基本了解,就可以理解整个图景。在理解了这些类之后,您应该能够根据自己的需求和愿望修改和扩展代码。在我简要描述核心类之前,让我们先看看主要的类层次结构(斜体表示抽象类):

1. EquationBase
这是一个抽象类。它包含我们将使用方程编辑器排版的每个方程所拥有的基本特征。该类是我们即将创建的所有方程的最终基类。方程模型中的其他每个类都必须继承自该类或其后代之一。
2. EquationContainer
此类也是抽象的。正如每个方程都必须的那样,它派生自EquationBase
。此类是希望在其内部托管其他方程的每个方程的基类(因此得名!)。此类实际上是我们想要实现的方程嵌套功能的骨干。
3. TextEquation
此类允许我们将Unicode文本添加到文档中。存储和绘制所有文本内容是此类负责的。此类本身不是容器(即,您不能在其内部嵌套另一个EquationBase
),因此它直接派生自EquationBase
。
4. EquationRow
此类是所有需要水平排列的方程的主要容器。它支持文本方程以及其他容器方程的嵌套。EquationRow
中的所有容器方程都必须放在TextEquation
实例之间。这样,我们就能够允许用户在需要的地方输入文本和更多容器方程。
5. RowContainer
就像文字处理器中段落的行或章节的段落一样,方程需要能够以水平线进行排版。RowContainer
是支持这种功能的类。它唯一包含的是EquationRow
类的实例。所有其他方程然后嵌套在这些EquationRow
对象中。
6. RootEquation
顾名思义,此类是由更高级别的GUI容器创建的第一个对象。然后,此类创建一个单一的RowContainer
来启动一切。然后,每个后续的方程都在那个单一的RowContainer
对象内创建。
一些值得关注的要点
上面讨论的6个核心类构建了整个模型的骨干。从这里开始,我们就可以开始实现更实用的方程,如括号、除法、积分等。然而,从这一点开始构建这些类型的方程几乎是微不足道的。所有其他容器类都派生自EquationContainer
并创建了一个或多个RowContainer
实例。
为了方便开发,我已将所有方程类(包括核心类)放入项目目录中一个名为equations的文件夹中。除了核心类,所有其他方程都位于它们各自的子文件夹中。
实现撤销/重做
这实际上是最具挑战性的任务之一。我们可以通过利用用于保存文件的序列化例程来使其非常简单。但这效率太低。最高效的方法似乎是让主要的方程类管理自己的状态,并在被告知时能够恢复到以前的状态。然而,使用这种方法,过程会变得如此复杂,以至于我们需要为此构建一个完整的子系统。
撤销/重做子系统由UndoManager
类管理,该类负责跟踪和调度撤销/重做事件。将参与撤销/重做堆栈的主要方程必须实现以下小型接口
public interface ISupportsUndo
{
void ProcessUndo(EquationAction action);
}
EquationAction
是所有将传递的类的基类,当UndoManager
类调用ProcessUndo(EquationAction action)
方法时。EquationAction
的定义如下
public abstract class EquationAction
{
public bool UndoFlag { get; set; }
public ISupportsUndo Executor { get; set; }
public int FurtherUndoCount { get; set; }
public EquationAction(ISupportsUndo executor)
{
Executor = executor;
UndoFlag = true;
}
}
EquationAction
的UndoFlag
允许被调用者弄清楚是否应该从方程树中添加或删除某些内容(即,这告诉了撤销/重做过程的方向!)。
好消息是,由于我们健壮的面向对象模型,尽管撤销堆栈的复杂性很大,但只有少数主要方程应该能够代表所有当前和未来的方程管理所有撤销/重做操作!实际上,只有以下三个主要的方程类实现了ISupportsUndo
接口
EquationRow
RowContainer
TextEquation
不过要小心,这些类中撤销/重做堆栈的实际实现绝非易事,因为它们必须一直记住整个方程树,因为它正在被构建和修改!
结论
让我们总结一下我们试图传达的内容
- 面向对象设计过程不是一套严格定义好的原则或实践。它更像是一门艺术,而不是一门科学。
- 发现构成模型实体的共同特征和行为的能力是良好面向对象设计的关键。
- 抽象是灵活和可扩展的面向对象模型的基石。
- 当要表示的模型相对抽象时,定义对象和关系更具挑战性。
如果您发现任何遗漏或不清楚的地方,请告诉我。您的反馈一定会帮助我做得更好,更有用。