OpenCalc — React Native —深入研究(第2部分)

这是OpenCalc的2部分系列的第2部分,OpenCalc是使用react-native,javascript和flow构建的开源移动计算器。 第一部分处理设计和UI组件,而上一篇文章则介绍了如何在应用市场中编写该应用程序。 第二部分将处理计算和验证。 OpenCalc在iOS和Android上可用。

主控制器具有一个称为Brain的属性,它是CalculatorBrain的一个实例 控制器调用以下大脑功能:

  brain.clear()//清除大脑队列 
brain.setItem(button)//将一个项目添加到队列
brain.deleteLast()//删除队列中的最后一项
brain.getDisplay()//返回队列的文本显示
brain.getResult()//返回评估队列的结果

大脑的主要目的是处理一系列操作。 操作操作文件中定义。

运作方式

Operations.js提供了许多与定义,存储和使用Operations相关的功能。 Operations文件的主要目的之一是定义Operation类。

操作课

Operation构造函数如下:

 构造函数 
stringVal:字符串,
operationType:数字,
operationSubType:数字,
val:任何,
优先级:?数字,
operationArgs:?Set

stringVal只是显示中的字符串表示形式。 operationType和operationSubType仅包含一个值,以帮助验证器确定操作是否合法以及如何处理每个操作。 类型和子类型只是枚举值。

val用于存储与运算符相关的功能。 优先级是操作(例如PEMDAS)的优先级。 例如,+和*的优先级分别为2和4。 因此,当计算器计算1 + 2 * 3时,它会优先执行*,因为它具有较高的优先级。 最后,operationArgs只是一组存储特殊情况的枚举。 例如,pi是一个数字,但应将其打印为字符串而不是数字表示形式。

将所有可用估值存储在计算器中

操作是计算器可用的各种操作的字典。 在此屏幕上添加新操作或更改操作方式很容易。 操作示例:

  '+':操作( 
stringVal ='+',
operationType = OperationType.Operation,
operationSubType = OperationSubType.BinaryOp,
val = function(x,y){return x + y; },
优先级= 2.0

“ +”是二进制运算(具有两个输入),其值等于x + y。 优先级为2.0。 词典的键是“ +”,它也是计算器视图上按钮的文本。 因此,控制器将“ +”传递到大脑,然后大脑知道相关的操作要添加到其队列中。

优先级最高和最低的项目是括号:

  '(':new Operation('(',OperationType.Parenthesis,OperationSubType.ParenthesisOpen,null,7.0), 
  ')':new Operation(')',OperationType.Parenthesis,OperationSubType.ParenthesisClose,null,-7.0) 

括号由大脑单独处理。 从左到右扫描队列,并将括号优先级应用于括号后的所有值。 因此,打开/关闭括号会取消优先级更改。

例如,请记住+和*的优先级分别为2和4:

 队列:1 + 2 * 3 
原始优先级:2 4
原始评估:2 * 3 => + 1 => 7
 列:(1 + 2)* 3 
原始优先级:2 4
括号优先级:7 -7
修订后的优先重点:9 4
修订后的评估:1 + 2 => * 3 => 9

CalculatorBrain中的函数valuateParenthesis执行此操作是返回删除了括号并修改了操作员优先级的队列。

生成新的操作对象

大脑使用函数newOperation生成要添加到其队列中的新对象。 它接受三个输入并返回一个Operation

 函数newOperation( 
opString:字符串,
队列:?Array ,
operationArgs:?Array
):操作{...}

它沿着队列传递,这只是一个操作数组类型。 由于过载的操作,因此需要这样做。 newOperation函数要做的第一件事是检查过载操作。 重载操作是一个操作,根据队列中的先前操作,它可能具有多种含义。 例如,“-”可以表示负号或负号。 大多数计算器都有一个用于减号和减号的单独按钮,但是我不想放弃这个领域。

我唯一的重载操作是“-”,它可以是binaryOp(例如1-2)或unaryOp(例如1 * -2):

  const OperationsOverloaded = Object.freeze({ 
'-':Object.freeze({
一元运算:“ neg”,
BinaryOp:“-”
})
});
 函数getOverloadedOperation( 
opString:字符串,
队列:?Array
){
const opOverload = OperationsOverloaded [opString];
如果(opOverload &&队列){
const lastOp = queue [queue.length-1];
如果(lastOp && isInArray(
lastOp.operationSubType,
数组(
OperationSubType.Constant,
OperationSubType.ParenthesisClose,
OperationSubType.BackwardUnaryOp)

){返回opOverload.BinaryOp; }
其他{
返回opOverload.UnaryOp;
}
}
返回opString;
};

上面的getOverloadedOperation函数检查队列中最后一个操作的operationSubType。 如果它是恒定的,封闭的括号或向后一元运算符,则从上下文中知道“-”是二进制运算符(减号)。 否则,它是一元运算(取反)。

下面的例子:

 什么类型的操作是“-”? 
 输入:1 {-} 
队列:[恒定]
结果:binaryOp
 输入:1 * {-} 
队列:[constant,binaryOp]
结果:一元运算

四舍五入

roundToZero函数是几何运算(例如,sin,cos,tan)的包装。 因此,例如,操作cos的函数映射为:

 函数(x){返回roundZero(Math.cos,x)} 

这是必需的,因为这些应用程序使用的是javascript数学模块,并且浮动用于我们的几何函数。 例如,Math.sin(Math.PI)=== 1.22464e-16。 尽管不是很完美,但是roundZero函数可以解决此问题。 一般舍入在应用程序的其他位置进行处理,但仅舍入最终结果。 此外,pi仍然表示为近似值,而sin(pi)的值实际上为0。

roundZero函数如下:

 函数roundZero(func,x){ 
const out = func(x);
返回(Math.abs(out)<Number.EPSILON)吗? 0:熄灭;
}

计算器脑

大脑是大部分繁重的工作发生的地方。 它也是最大的文件,大约有250行。 完整的实现可以在这里找到。

尽管它的长度和复杂性,但大脑仅做两件事:

  1. 管理操作队列
  2. 根据队列为控制器生成显示

队列管理

该队列只是一个类型为Operation的Array。 更改队列的功能如下:

  brain.clear()//清除大脑队列 
brain.setItem(button)//将一个项目添加到队列
brain.deleteLast()//删除队列中的最后一项

清除只是清除队列。 setItem函数将操作添加到队列,而deleteLast通过从队列中删除最后一个有效操作来更改队列。

设定项目

根据用于触发事件的按钮的文本值,将一项作为字符串传递给大脑。 然后, setItem函数根据上一节中讨论的Operations文件中的函数创建一个新的Operation值。 但是根据操作的操作类型,大脑会将操作传递给不同的功能。

可以传递四种操作类型:等于,常量,运算和括号。 setItem函数检查operationType并转到一个单独的函数,该函数检查操作的有效性并返回要添加到队列中的实际操作。 然后,它将此操作添加到队列中。

  setItem(item:string){ 
let op:Operation = newOperation(item,this.queue);
让opToAdd:?Operation;
让清除=假;
如果(op.operationType == OperationType.Equals){
opToAdd = this.setEquals(op);
已清除= true;
}否则,如果(op.operationType === OperationType.Constant){
opToAdd = this.setOperand(op);
}否则,如果(op.operationType === OperationType.Operation){
opToAdd = this.setOperator(op);
}否则,如果(op.operationType === OperationType.Parenthesis){
opToAdd = this.setParenthesis(op);
}
 如果(opToAdd){ 
this.queue.push(opToAdd);
如果(opToAdd.operationArgs.has(OperationArgs.AddParenthesis)){
this.queue.push(newOperation('('));
}
this.cleared =已清除;
}
}

设定括号

setParenthesis函数是这四个函数中最简单的。 它只是进入确定括号有效性的Validator,如果有效,它将返回传入的Operation 。括号并不总是有效的(例如,没有早期开放括号的封闭括号),因此确定有效性是Validator的工作。 稍后我将详细介绍验证器。

  setParenthesis(op:Operation){ 
如果(Validator.validParenthesis(op,this.queue)){
返回op;
}
返回null;
}

设定运算子

对于设置运算符,我们只需要联系验证器并提供该运算符和上下文,并确定是否需要替换最后一个运算符以及它是否是有效的运算符。 最后一个运算符可能需要替换。 例如,如果用户输入“ 1 + *”,我们希望最终输出为“ 1 *”,因为*表示要替换输入的+。

  setOperator(op:Operation):?Operation { 
如果(Validator.replaceOperator(op,this.queue)){
this.queue.pop();
}
 如果(Validator.validOperator(op,this.queue)){ 
返回op;
}
}

设置等于

所有功能都交给验证器来确定操作的有效性,setEquals也不例外。 等值运算唯一无效的时间是队列长度小于2。例如,如果键入“ 1 =”,则由于没有要执行的运算,结果将为空。 因此,允许等于将清除队列,这不是我们想要的。 请注意,这并不完美,因为您可以输入“ sin(cos(”,然后等于),这只会清除输入。

但是,通常情况下,等于仅评估队列,并将结果放入显示字段中。 队列的评估方法是通过大脑的getResult函数。 该函数的问题在于它返回一个字符串。 因此,我们的setEquals需要评估队列,替换逗号并以运算符的形式返回结果,以将其添加到队列中。 它还必须清除队列。

我不喜欢我没有返回结果的数字表示的事实,如果我要再做一次,我会添加它并让转换成字符串的方式发生在其他地方。

  setEquals(op:Operation):?Operation { 
如果(Validator.validEquals(op,this.queue)){
const strResult:字符串= this.getResult();
const结果:字符串= strResult.replace(/,/ g,'');
const opArgs:Array = new Array(OperationArgs.Cleared);
const opToAdd = newOperation(result,this.queue,opArgs);
this.clear();
返回opToAdd;
}
}

设置操作数

操作数是要对其进行操作的值(例如,常量)。 像其他set函数一样, setOperand函数会发给Validator以根据其上下文验证操作符。 由于我认为小数点是操作数,因此有时您可能会因为无效而忽略该操作(例如“ 1.1。”)。

如果该操作有效,那么我们需要考虑最后一个操作。 如果最后一个操作是一个常量,则需要将值附加到该常量。 例如,如果队列中已经有一个“ 1”并且添加了另一个“ 1”,我们希望在队列中为“ 11”添加一个操作。

此规则的一个例外是是否只是清除了队列。 在这种情况下,我们不想将新常量添加到队列常量中,而是替换原始常量并使用此常量。

另一个例外是常量不可解析。 例如,如果有人输入“π” 然后是“ 1”,我们不想看到“π1”,而只是用“ 1”代替π。

以下是setOperand的完整实现:

评估队列

计算器还评估队列。 这意味着它将操作队列并计算结果。 这是通过getResult完成的,该方法返回一个字符串,该字符串表示所评估操作的文本值。 每次在计算器上按下一个按钮,就会调用getResult 。 我不必费心执行任何缓存或其他优化,因为这是不必要的,并且我不想介绍复杂性。

getResult要做的第一件事是将队列分为数字和运算。 接下来,它评估括号并修改操作以反映周围的括号。 然后,它调用validate ,并传递数字,运算和任何累加值。

评估

求值是保留累加器的递归函数。

考虑以下输入:1 + 2 * log(3)/4。以下数组将作为数字传递:[1、2、3、4],操作:[+,*,log,/],acc:没有]。 评估将执行以下步骤:

  1. 用索引将操作压缩两次。 第一个值代表操作索引,第二个值代表与操作关联的数字。 因此在我们的示例中,压缩后的队列看起来像:(0,0,+),(1,1,*),(2、2,对数),(3,3,/)。 元组(0,0,+)表示+运算是第0个运算,并以第0个数字作为第一个输入。
  2. 下一步是遍历所有操作,查找一元操作,并将跟在一元操作之后的任何元组的数字索引调整一个。 因此,执行完此操作后,我们的压缩索引看起来像(0,0,+),(1,1,*),(2、2,对数),(3,2,/)。 您可以看到,最后一个操作现在作用于第二个数字索引。
  3. 查找最大优先级操作。 如果它是一元运算,并且有两个具有相同优先级的运算,则优先选择最右边的优先级(例如sin cos 0 === sin(cos 0))。 否则,如果两个操作的优先级相同,则从左到右执行操作(例如1 * 2/3 ===(1 * 2)/ 3)。 在我们的示例中,最高优先级是log。
  4. 将压缩的操作解压缩到其组件中:操作索引,数字索引和操作。 将数字和操作弹出各自的队列,然后尝试根据数字索引引用相关的数字。 如果它是一元运算符,则仅使用第一个数字索引,或者如果其二进制数则使用两者。 在我们的示例中:log(3)=> 0.477
  5. 更新累加器并将累加值,剩余数字/运算数组传递回评估函数:数字:[1、2、0.477、4],运算:[+,-,/],acc:0.477

取得展示

getDisplay函数仅映射队列并返回字符串表示形式。 它还会格式化数字以显示逗号或科学计数法。

大脑利用Validator对象和Utils文件中的一些功能。

实用程序

Utils文件具有许多通用功能。 以下是整个列表。

  zip //例如f(((a,b),(c,d))=>(((a,c),(b,d)) 
zipWithIndexTwice //例如f(a,b)=>((0,0,a),(1,1,b))
lastOrNull //例如f([])=> null
isNumeric //例如f(“ 123.a”)=> False
isInArray //例如f(1,[2,3,1])=> True
小数位数// //例如f(“ 123.45”)=> 2
相乘//例如f(1.2,3.45)=> 4.14
useExponential //例如f(123456789)=> True
numberWithCommas // //例如f(123456789)=> 123,456,789

这些大多是简单明了的,但是乘法和numberWithCommas如果不是很顽皮的话就很有趣,值得一读。

您可能已经猜到了,乘法函数只接受两个数字输入,然后将它们相乘返回一个数字。

 导出函数乘法(a:数字,b:数字):数字{ 
const aDecimals = decimalPlaces(a.toString());
const bDecimals = decimalPlaces(b.toString());
const maxDecimals = Math.max(aDecimals,bDecimals);
const multiplier = Math.pow(10,maxDecimals);
const除数= Math.pow(10,(maxDecimals * 2));
const结果=(a *乘数* b *乘数)/除数;
返回结果;
}

需要使用乘法功能来处理javascript的浮点表示形式。 例如:

  js> .1 * .2 
0.020000000000000004

可能有一些类型可以表示小数,但是由于这仅适用于乘法,因此我只编写了自己的简单乘法函数。

最好用一个例子来描述乘法功能

 乘(0.1,0.23) 
a = 0.1; b = 0.23
aDecimals = 1; b小数= 2
maxDecimal = max(1,2)= 2
乘数= Math.pow(2)= 100
除数= Math.pow(10,2 * 2)= 10000
结果=((0.1 * 100)*(0.23 * 100))/ 10000
=(10 * 23)/ 10000
= 230/10000
= 0.023

代码如下:

 导出函数乘法(a:数字,b:数字):数字{ 
const aDecimals = decimalPlaces(a.toString());
const bDecimals = decimalPlaces(b.toString());
const maxDecimals = Math.max(aDecimals,bDecimals);
const multiplier = Math.pow(10,maxDecimals);
const除数= Math.pow(10,(maxDecimals * 2));
const结果=(a *乘数* b *乘数)/除数;
返回结果;
}

带逗号的数字

numberWithCommas函数接受字符串输入和默认为true的舍入布尔值。 然后,该函数将字符串转换回数字。 有一个本地JavaScript toLocaleString,但在Android上不起作用。

如果将舍入布尔值设置为true,则首先根据Configs文件中的MaxPrecision对数字进行舍入。

然后,它通过useExponential函数检查该数字是否应显示为指数。 如果是这样,它将仅返回指数表示形式。

否则,该函数将字符串表示形式除以小数位。 这个想法是我们只想在小数点左边的数字上添加逗号(例如1234.5678 => 1,234.5678)。 然后,它使用正则表达式查找逗号应放在何处,并相应地插入逗号。

最后,它将头(小数点左边)和尾部(小数点右边)连接起来并返回字符串表示形式。

完整的实现如下。

 导出函数numberWithCommas(x:string,round:?boolean = true){ 
const xNumber = Number(x);
const xString =(
圆&&
(decimalPlaces(x)> Configs.MaxPrecision))?
xNumber.toFixed(Configs.MaxPrecision):x;

const parseRegex:RegExp = / \ B(?=(\ d {3})+(?!\ d))/ g
如果(useExponential(xNumber)){
返回xNumber.toExponential(Configs.ExponentialDecimalPlaces);
}其他{
// toLocaleString在Android中不起作用
const parts = xString.split(DECIMAL);
const head = parts [0] .replace(parseRegex,COMMA)|| '0';
const tail =(parts.length> 1)? parts [1] .toString():'';
const decimalJoin =(xString.indexOf(DECIMAL)<0)? '':十进制;
返回头+小数联接+尾;
}
}

计算器剩下的唯一主要部分是验证器,该验证器根据队列来验证输入是否有效。

验证器

Validator对象在给定队列的情况下验证输入。 例如:

  Validator.validOperator(op:“ +”,queue:[1,+,1])=> True 
Validator.validOperator(op:“ *”,queue:[sin])=>假
  Validator.validDigit(digit:1,队列:[2])=> True 
Validator.validDigit(digit:1,queue:[2,%])=>否
  Validator.validEquals(op:“ =”,队列:[1,+,1])=> True 
Validator.validEquals(op:“ =”,队列:[1,+])=> False
  Validator.validParenthesis(op:“)”,队列:[(,1,+,1])=> True 
Validator.validParenthesis(op:“)”,队列:[1,+,1])=> False

如果验证者说某项操作无效,那么大脑只会忽略输入。

一方面,验证器很好地封装了验证输入是否正常的功能。 但另一方面,它只是基于我能想到的每种可能情况的一系列if语句。 我没有坐下来考虑所有情况,而是随着时间的推移对它进行了更新,因为我发现奇怪的输入有效,或者适当的输入无效。 没有示例很难理解,因此我发现编写测试很有用。

另一个函数是replaceOperator ,它仅根据提供的运算符返回是否应替换队列中的最后一个运算符。

例如:

  Validator.replaceOperator(op:“ *”,queue:[1,+])=> True 
Validator.replaceOperator(op:“ *”,queue:[1])=> False

第一个示例,*代替+。 在第二个示例中,它不应该。 这使某人无需使用退格键就可以替换他们刚刚写的运算符。

完整的实现如下。

最后的想法

编写模仿Google计算器的自然计算器并非易事。 队列的实际评估可以通过两个堆栈很好地表示。 现在开始包括操作顺序,并且变得更加复杂。 而且,当您开始包括范围更广的运算(例如向后一元运算(例如,%、!)和重载)(例如,“-”表示负数和减号)时,它的复杂性将变得更加复杂。 然后是有效的验证条目:浮点表示和适当的舍入,指数的使用以及数字的字符串表示。

困难解释了为什么许多计算器不理会这些功能。 iPad应用程序商店中的一些顶级计算器甚至都不需要验证输入,因此它们可以让您进行“ 1 + * 4”之类的怪异计算,并且如果没有意义就不会显示任何结果。

总的来说,这种经历使我更加赞赏Google计算器。 和设计它的开发商。 我什至没有尝试模仿他们的设计。 例如,10,000! 在Google计算器上工作(答案是2.84E + 35659)。 因此,他们显然正在使用比任何实际方法都大得多的数值类。 而且计算器并没有真正舍入。 例如,如果值为0.66666…,则结果为2/3,并且您可以将结果向右滚动,使其变为…666666666666E-11,依此类推,直至达到任意精度。

但是,最有价值的部分是从头开始设计和开发应用程序。