技术 Swift计算器的设计逻辑

Stanford Paul Hegarty,大约在2017年的iOS开发课程中讲过用swift写计算器app的例子,后来,特别是在iOS开发从MVC框架,转到MVVM框架上来的时候,他已经不再使用这个例子。

但我个人认为,计算器的设计逻辑,对于理解swift语言,是一个很好的入门例子,它比较简单(虽然从完整app的角度,其实一点都不简单),能比较快地写出demo。

下面的内容里面,如果有不准确的地方,是由于我个人的理解。这个文章会持续更新和修改。

大的框架定义:

struct Cal {
	// 定义一个内部的参与计算的变量,这个变量从外部无法访问,只能从内部设定
	private var accmulator: Double? 
	
	// 计算引擎函数
	func perform(_ symbol: String) {
	
	}
	
	// 赋值函数,它负责把外部值赋给内部变量,因为内部变量可变,所以它是mutating
	mutating func setOperand(_ operand: Double) {
		accumulator = operand
	}
	
	// 结果取值接口,它对外暴露,并且通过 get 设定为当前结果只读模式
	var result: Double {
		get {
		    return accumulator!
		}
	}
}

上面是一个经典的数据处理引擎的设计方式。

这种设计方式的好处是:

  • 易于测试。内部运算变量的值肯定是通过 setOperand这个接口被设定的,而不会是由其它调用函数的设定。对于追踪来源很明确。
  • 它的结果是只读的,也就是说,这个结果肯定是在函数的内部产生的,不会被外部干扰和修改。
  • 进和出,保持一致。

针对上述代码的外部调用方式示例:

private var brain = Cal()

//传入变量,计算器上显示的数值
brain.setOperand(displayValue)

//执行计算
brain.performOperation(symbol)

//如果有计算结果
if let result = braind.result {
	displayValue = result
}

计算引擎核心部分的设计:

switch case

struct Cal {
	private var accmulator: Double? 
	
	mutating func perform(_ symbol: String) {
		switch symbol {
			case "pi":
				accumulator = Double.pi
			default: 
				break
			}
		}

一个最mini的演示版本已经可以运行。

上述演示版本,存在的明显问题:

如果要加更多的操作,只能增加更多的switch case;

可以处理直接得出结果的 Double.pi, 但如果类似 5*4=20,参数该如何传入?

dictionary

先解决第一个部分,重复添加switch case的问题:

首先的想法就是,设计一个表格,把对应的操作存储在里面,使用的时候在表格中查询对应的操作。

在swift中,使用Dictionary来承担上述表格的角色。

private var operations: Dictionary<String, Double> = 
[
	"pi" : Double.pi, 
	"e" : M_E
]

mutating func perform(_ symbol: String) {
	if let constant = operations[symbol] {
		accumulator = constant
	}
}

这样就完成了字典的设计。 因为字典可以是任何类型,所以在 <> 指明了对应的类型 String 和 Double。 通过把常量加入到字典里的方式,比上面的 switch case 要简洁一些。

enum

接下来,我们将见识到:

  • 旧的问题被解决之后,新的问题随之涌现;
  • 为解决新出现的问题,引入新的工具;
  • 后出现的问题被解决,新的问题随之涌现。

通过上述循环反复的过程,swift的 type “类型”将陆续登场。

上面的字典定义,解决了不停地塞 switch case 的问题,但也有它自身的问题,就是字典定义里面,接受的参数限定为:String 和 Double,那么象 开根号 这样的操作,没有办法放到字典里面。因为后者是带一个参数的函数。

于是接下来,引入一个新的类型,enum。通过 enum 来对应一类明确的操作类型。

private enum Operation {
	case constant //定义一类关于常数操作
	case unaryOperation //定义象开根号这种一个变量的操作
}

private var operations: Dictionary<String, Operation> = 
[
	"pi": Operation.constant, 
	"cos": Operation.unaryOperation,
    "sqrt": Operation.unaryOperation
]

现在,计算引擎演变成上面的样子。它能够包容定义的不同类型的操作。

但是呢,上面的方法也仍然存在一个问题,就是它把之前定义的具体操作函数,比如 Double.pi 丢掉了,变成了一个通用的,但无法执行具体计算的类型。

接下来将出场计算引擎设计里面,几乎是最核心的一个部分:

associated value

associated value 的作用类似于,把数据 attach 到 enum 的类型里面。这样 enum 带着数据参数及其类型,就可以映射到 dictionary 里进行具体的操作计算了。

在实际使用中,通过 “let” 获取enum类型里的associated value,代码如下:

private enum Operation {
	case constant(Double)
	case unaryOperation( (Double)->Double ) 
    //括号里的部分:(Double)->Double 其实是一个函数,如 sqrt(36)
}

private var operations: Dictionary<String, Operation> = 
[
	"pi" : Operation.constant(Double.pi), 
	"sqrt" : Operation.unaryOperation(sqrt)
]

mutating func performOperation(_ symbol: String) {
	if let operation = operations[symbol] {
		switch operation {
			case .constant(let value):  //情况一
				accumulator = value
			case .unaryOperation(let function):  //情况二
				if accumulator != nil {
					accumulator = function(accumulator!)
					// 把函数作为一个type来调用
				}
			}
		}
	}
}

重新理一遍逻辑:

  • -> 定义一个字典,来查询对应的操作;
  • -> 原生字典,对应的操作类型有限,引入 enum 来定义类型;
  • -> enum如果只定义类型,无法进行具体操作,于是引入assocate value;
  • -> 函数也是一种类型,在assocate value里,通过函数解决计算问题。

再进一步,
解决剩下的问题,即类似于 5 * 3 = 15 这样的通用问题。

private enum Operation {
	case constant(Double)
	case unaryOperation( (Double)->Double ) //括号里就变成了一个函数
	case binaryOperation( (Double, Double) -> Double)
	case equals  //这里我们没有定义任何associate value,后面会直接绑定一个函数
}

private var operations: Dictionary<String, Double> = 
[
	"pi" : Operation.constant(Double.pi), 
	"sqrt" : Operation.unaryOperation(sqrt), 
	"x": Operation.binaryOperation(multiply),
	"=": Operation.equals
]

private func multiply(op1: Double, op2: Double) -> Double {
	return op1 * op2
}

接下来的操作比较复杂一些,
我们要想一个结构,它能够包进去两个变量, 3 和 5,还要包进去操作符号: + - x /

因为 struct 能够包括变量和函数,所以就用 struct 来定义这个结构

private struct PendingOperation {
	let function: (Double, Double) -> Double //定义操作
	let firstOperand: Double
	
	func perform(with secondOperand: Double) -> Double {  // with 只是一个易懂的表达名称,不是关键字
		return function(firstOperand, secondOperand)
	}
}

之所以要把 secondOperand 作为一个外部参数传进去,而不是直接在 PendingOperation里面计算出来,是因为,计算器 的 “ = “ 才负责出结果的这一步操作。
而 + - x / 这些符号,负责 pendingOperation 的操作。

把上面的结构定义好以后,就可以开始串起全流程:

private var pendingOperation = PendingOperation? 
// 问号是因为用户没按 + - 就不存在这个变量

mutating func performOperation(_ symbol: String) {
	if let operation = operations[symbol] {
		switch operation {
			case .constant(let value):  //情况一
				accumulator = value

			case .unaryOperation(let function):  //情况二
				if accumulator != nil {
					accumulator = function(accumulator!)
					// 函数可以通过这样来调用,参见上面的第9
				}
			
			case .binaryOperation(let function):
				if accumulator != nil {
					pendingOperation = PendingOperation(function: function, firstOperand: accumulator! )
					// 是对上面的结构的初始化
					accumulator = nil
					// 把它置空,以等待放入第2个数据
					}
					
			case .equals:
				performPendingOperation()
		}
	}

	private muatating func performPendingOperation() {
		if pendingOperation != nil && accumulator != nil {
			// 把前面保存的临时数据操作结构拿出来,计算出结果
			accumulator = pendingOperation!.perform(with: accumuator!)
			pendingOperation = nil
		}
	}

至此,功能全部设计完备。但是程序还留下一个小瑕疵。

看上面的:

private func multiply(op1: Double, op2: Double) -> Double {
	return op1 * op2
}

private var operations: Dictionary<String, Double> = 
[
	"pi" : Operation.constant(Double.pi), 
	"sqrt" : Operation.unaryOperation(sqrt), 
	"x": Operation.binaryOperation(multiply)
]

如果我们要往字典里面加入更多的自定义操作,比如: / - + 就要在外部定义更多的自定义函数。

这个时候,我们引入闭包。

从而简化成:

private var operations: Dictionary<String, Double> = 
[
	"pi" : Operation.constant(Double.pi), 
	"sqrt" : Operation.unaryOperation(sqrt), 
	"x": Operation.binaryOperation($0 * $1),
	"+": Operation.binaryOperation($0 + $1)
]

待优化 post status: to be refined

tag: 编程 python swift