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行。 完整的实现可以在这里找到。
尽管它的长度和复杂性,但大脑仅做两件事:
- 管理操作队列
- 根据队列为控制器生成显示
队列管理
该队列只是一个类型为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:没有]。 评估将执行以下步骤:
- 用索引将操作压缩两次。 第一个值代表操作索引,第二个值代表与操作关联的数字。 因此在我们的示例中,压缩后的队列看起来像:(0,0,+),(1,1,*),(2、2,对数),(3,3,/)。 元组(0,0,+)表示+运算是第0个运算,并以第0个数字作为第一个输入。
- 下一步是遍历所有操作,查找一元操作,并将跟在一元操作之后的任何元组的数字索引调整一个。 因此,执行完此操作后,我们的压缩索引看起来像(0,0,+),(1,1,*),(2、2,对数),(3,2,/)。 您可以看到,最后一个操作现在作用于第二个数字索引。
- 查找最大优先级操作。 如果它是一元运算,并且有两个具有相同优先级的运算,则优先选择最右边的优先级(例如sin cos 0 === sin(cos 0))。 否则,如果两个操作的优先级相同,则从左到右执行操作(例如1 * 2/3 ===(1 * 2)/ 3)。 在我们的示例中,最高优先级是log。
- 将压缩的操作解压缩到其组件中:操作索引,数字索引和操作。 将数字和操作弹出各自的队列,然后尝试根据数字索引引用相关的数字。 如果它是一元运算符,则仅使用第一个数字索引,或者如果其二进制数则使用两者。 在我们的示例中:log(3)=> 0.477
- 更新累加器并将累加值,剩余数字/运算数组传递回评估函数:数字:[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,依此类推,直至达到任意精度。
但是,最有价值的部分是从头开始设计和开发应用程序。