带图形的方程计算器
带图形的方程计算器

该项目的重点是实现一个方程式解析器,最初的 UI 只是为了测试解析器而添加的,但它慢慢地发展成了具有图形功能的计算器。
方程式解析器
我对解析器的要求是允许表达式像手写一样输入(单行),并明显地保持正确的运算顺序,允许常数和隐式乘法。
隐式乘法意味着在项之间不需要乘号,例如,数字*项,数字*常数。例如,如果一个表达式写成 (2+3)(2+3),它将被解析为 (2+3)*(2+3)。2xy 也是如此,它被解析为 2*x*y。
运算顺序是先括号,从最内层开始,然后是指数(幂),然后是乘除,最后是加减。对于乘除和加减,运算是从左到右进行的。
通过实现这个顺序,表达式 2*5^2 将被解析为 2*(5^2) = 50,而如果从左到右解析,结果将是 2*5^2 = 100 (2*5 = 10, 然后 10^2 = 100, 错误)。
当前实现中的一个小的不足是隐式乘法不比“常规”乘法优先级高,这意味着表达式 1/2x 将被解析为 1/2*x 而不是 1/(2*x)。例如,如果 x 是 5,那么结果是 1/2*5 = 2.5,而不是 1/(2*5) = 0.1,这可能出乎意料。
解析器实现

解析器实现位于 CommonUtils
目录下的 EquationParser.cs。
为了实现括号的顺序,解析是作为 Term 中的递归解析实现的,其中每一对括号都会创建一个新的项。Term 包含一个 EquationElements
堆栈,在第一遍解析时,元素会随着解析而添加到堆栈中。当找到一个括号时,会找到匹配的结束括号,并基于该子字符串创建一个新的项,然后将这个新项添加到当前项的堆栈中。
例如,2*(3+5) 将导致堆栈如下所示

如果一个解析的元素是 EquationValue
类型,并且堆栈中的前一个元素也是同一类型,那么就假定为隐式乘法,并向堆栈添加一个 * 运算符。
在将值添加到堆栈时,还会进行另一个检查,即检查符号运算符。如果添加了一个值,并且堆栈中的前两个元素是运算符元素,且前一个是 – 运算符,那么将从堆栈中删除该运算符,并将符号标志设置为该值。
项内的任何计算都是从左到右(从上到下)进行的,因此下一步是根据运算顺序创建新的项。
首先,进行一次遍历,为指数 ^ 运算符创建新项。如果找到给定的运算符,则创建一个新项,并将左值、运算符和右值添加到该项。然后,如果下一个运算符是同一类型,则将该运算符加上后续值添加到同一项中。
第二次遍历是组合 * / 到项中。
这是 Term.Parse
中的代码
public void Parse(string equation, EquationElement root)
{
Stack.Clear();
Parse(equation, 0, root);
CombineTerms(new char[] {'^'});
CombineTerms(new char[] {'*', '/'});
}
在解析过程中,会询问每种类型的元素,当前方程式的位置是否是该类型已知的。它们被询问的顺序是
- 运算符
- 数字
- 恒定
- 函数
- 变量
- 术语
- 抛出异常
在解析过程中,如果遇到任何错误,都会抛出异常。
- Operator 查找 */+-^
- Number 查找任何 ‘0’..’9’ 字符。
- Constant 查找 ‘pi’ 和 ‘e’
- Function 查找一些预定义的
static
函数 Variable
类(在 UI 中称为Constant
)会在根元素中查找匹配的名称。
结果值在 Term.Value
中返回。此调用会迭代堆栈并按堆栈中的每个元素执行计算。
单元测试
为了确保在实现过程中没有破坏任何功能,我创建了一套小的测试,在启动时执行。这些测试位于 EquationUnitTest
中,并且每添加一项新功能时都会添加一个测试。
限制
解析器的一个限制是 double
的精度。有可能输入一个应该得到整数值的简单表达式,但实际上显示的值在第 15 位小数处偏离 1。我尝试过将解析器更改为使用 decimal,因为它具有更高的精度,但 decimal 的范围太有限,所以我选择继续使用 double
。
另一个限制是缺少对复数ii的支持,目前我没有计划添加此支持。
计算器 UI
如开头所述,该项目的重点是方程式解析器,而不是 UI,所以我不会详细介绍它是如何实现的,因为它都是相当基本的 WPF。
我在计算器页面添加了以下功能。
快捷键选择堆栈 (Alt-1)、输入 (Alt-2) 和常量字段 (Alt-3)。这可能已经在 XAML 中定义了,但我选择用代码实现。
按 Enter 键执行计算。如果焦点在输入字段上,则清除该字段。如果焦点在常量字段上,则执行计算,但不会清除该字段。这允许为同一方程快速测试多个常量值。
输入字段和常量字段都会保留历史记录,可以通过向上/向下箭头或下拉列表访问历史记录。
如果输入的第一个字符是运算符,则插入 'ans
' 以继续使用前一个答案进行计算。例如 3+4 <Enter> + 1 <Enter> = 8。'Ans
' 可以在表达式中的任何位置使用。这将插入最后计算的值。
我没有添加任何数字键盘,因为我个人觉得使用键盘在文本框中输入值比使用鼠标点击按钮输入相同的值更容易。
我非常喜欢 Kaxaml 中使用的暗色风格,所以我试图创建一种类似的风格。风格是手动定义的,所以我在重新定义按钮样式时没有做任何花哨的动画。
图形
图形控件定义在 CommonUtils.GraphicalCanvas
中,称为 CanvasCtrl
。这仍然是正在进行的工作,但目前它支持多层、缩放(鼠标滚轮)和拖动(按住鼠标滚轮)。
目前,图形的功能相当有限。网格是固定的 20x20,其实现定义在名为 GraphGrid
的 CanvasLayer
派生类中。Center 会居中网格,Zoom 会设置缩放级别,使 10 个单位可见在一个方向上。
列表中每条方程式都会在图形上添加一层。这个 EquationLayer
在 OnRender
方法中重新计算其图形。当图形大小调整或被拖动时,图形控件会调用此方法。
绘制方程式图形时的问题是如何确定要计算的采样点数。采样点太少,图形就不准确;采样点太多,更新图形时就会非常迟缓。
经过几次尝试,我最终使用了图形的像素宽度作为采样点数。这对于任何变化平滑的图形都效果很好,但我遇到的下一个问题是尝试绘制如下函数 -0.1/(x-1),这个函数在变得未定义(x = 1)之前会趋近于正无穷大,然后又回到趋近于负无穷大。
我遇到的两个问题是
- 将从最大值绘制一条线到最小值,这在图形中显示为一条垂直线。
- 缩小视图时,由于采样点数少,线条会消失。
为了解决问题 #1,我添加了一个检查,当 y
值在正负之间(或反之)变化时,当检测到变化时,我会查看前一个采样点和当前采样点之间的斜率,如果斜率也发生了变化,则插入一个 NaN 值,然后使用这个 NaN 来中断图形,同时将 LineSegments
添加到 PathFigure
中。这在 OnRender
中完成。
对于问题 #2,我决定将一个采样间隔简单地分成 10 个采样点,然后找到最小值和最大值并将这些值添加到采样列表中。当图形放大时,这效果很好,但一旦缩小,采样间隔就太低了,图形就会失真。例如,tan(x) 的图形在放大时可以显示出一些非常有趣的模式,如下图所示,其中每个尖峰都应该趋近于无穷大。
下一步
我有一些功能想添加到图形中,一个是选择区域缩放,另一个是当鼠标移动时,对当前选定的图形进行 Y 值跟踪。一旦这些可用,我将更新项目。
修订
- 11/09/2010
- 上传了
double.Parse
的修复。现在 '.' 和 ',' 都可以作为小数点分隔符。 - 11/17/2010
- 添加了缺失的函数,并根据 Arjen 的建议修复了嵌套函数。
- 一个函数现在支持函数的多次嵌套,例如,“
cos(2min(pi(1/2), 3.14/2))
” - 添加了对将方程作为变量值的支持,例如
m_term.SetVar("x", "2pi");
- 这会将变量
x
设置为2*pi
。 Atan2
函数目前已移除,因为它没有被正确解析。- 11/25/2010
- 修复了幂运算符的运算顺序。如果一个项被提升到另一个幂,而这个幂又被提升到另一个幂,那么计算应该自上而下(从右到左)进行。在调用
CombineTerms
('^') 之后,我添加了CombineTopDown
作为额外的遍历。
现在 2^2^3 给出了正确的答案 256。
同时,符号标志从左项移到了新项,所以现在 -1^2 给出了正确的答案 -1 而不是 1。
以下测试已添加到单元测试中。
Assert("2^2^3", Math.Pow(2, Math.Pow(2,3)));
Assert("(2^2)^3", Math.Pow(Math.Pow(2, 2),3));
Assert("-(-1)^2", -1);
Assert("-1^2", -1);
Assert("(-1)^2", 1);
m_term.SetVar("x", 0);
Assert("-(x-1)^2+4", 3);
x2
,使用 Arjen 提供的修复程序支持。Atan2
尚不可用(忘记取消注释 EquationElements
中的第 736 行)。