Simple Scheme で電卓をつくってみる(5.7)

前回は電卓内で実行される計算の内、加減算の具体的なやり方を決めました。
電卓内で実際に保持される数はリスト型ではなくnum型です。なので、前回のコードを利用してnumの加減算を定義しましょう。

numは整数部分と小数部分をリストで保持するstructureです
前回決めたやり方で加減算をするには、加減算を行う2つの数の整数部分同士、小数部分同士でそれぞれ桁数を揃える必要があります。
それには、桁数が少ないほうに0を付加すればよいですね。整数部分は上位桁側に、小数部分は末尾桁側に付加することになります。付加すべき個数分の長さを持った0のみのリストを生成できると便利なのでそういう関数を定義します。

(define (get-padding xs n) (build-list (max 0 (- n (length xs))) (lambda (_) 0)))

8桁電卓では、桁をそろえたときに最上位から8桁目よりも後ろにある数は計算に使われないようです。
例えば、12.345678 - 9.3456778を計算すると、正しい値は3.0000002ですが、8桁電卓では3.000001になります。
9.3456778の整数部分の桁数を12.345678にそろえたとき、9.3456778の最後の8が9桁めになるため計算に使われなかったと考えると辻褄が合いますね。

というわけで数を8桁め*1まででそろえるために次のような関数を定義しましょう。

(define (trim xs n) (take (append xs (get-padding xs n)) n))

これを用いて桁を揃える―numから前回の方法で加減算ができるようなリストを生成する―関数を定義します。

(define (align x y)
  (let* ((ix (num-i x)) (iy (num-i y)) (li (max (length ix) (length iy)))
         (ix (append (get-padding ix li) ix)) (iy (append (get-padding iy li) iy)))
    (values li (trim (append ix (num-f x)) max-ndigits)
            (trim (append iy (num-f y)) max-ndigits))))

12.345678と9.3456778の桁をそろえてみましょう。

(align (make-num " " '(1 2) '(3 4 5 6 7 8))
       (make-num " " '(9) '(3 4 5 6 7 7 8)))

f:id:brv00:20191107211531j:plain:w250

最初の2は整数部分の長さです。

これで、num型の数を(小数点の位置と桁数がそろった)リストに変換できるようになりました。

足し算や引き算で得られた値を表すリストの先頭や末尾に余分な0がくっついている場合があります*2
なので0を取り除く関数を定義しましょう。

(define (drop0s xs) (if (and (cons? xs) (= (car xs) 0)) (drop0s (cdr xs)) xs))
(define (rdrop0s xs) (reverse (drop0s (reverse xs))))

これでnumに対する加減算を定義する準備が大体できましたが、リストで表された数に対する加減算はやり方しか書いていないので関数をちゃんと定義しておきます*3

(define (lop op xs ys)
  (foldr (lambda (x ds) (append (i/% (+ x (car ds)) 10) (cdr ds)))
         '(0) (map2 op xs ys)))

では加減算のうち、足し算関数、のほうを先に定義しましょう。名前はaddとします。

(define (add x y)
  (if (string=? (num-sign x) (num-sign y))
      (let*-values (((li xs ys) (align x y)) ((zs) (lop + xs ys))
                    ((zs li)
                     (if (= (car zs) 0) (values (cdr zs) li) (values zs (++ li)))))
        (make-num (num-sign x) (take zs li)
                  (rdrop0s (drop (trim zs max-ndigits) (min li max-ndigits)))))
      (sub x (make-num (num-sign x) (num-i y) (num-f y)))))

(異符号同士の足し算は絶対値同士の引き算が必要なのであとで定義する引き算関数のsubを呼び出して処理します)

(lop + xs ys)が返すリストは、長さが9になっています。最上位桁での繰り上がりがなければ先頭が0になります。
この場合は先頭を削ればよいです。 最上位桁での繰り上がりがある場合は先頭は0ではないので残さないといけません。なので末尾を削ることになります。
どちらを削ればよいかを判断するには先頭を見ればよいということですね。
先頭を削る場合は先頭を見た直後に削ります(cdrします)が、末尾を削る場合は先頭の値を見た直後でなく、計算結果を整数部分と小数部分に分けてからにしました。そして小数部分だけから削っています。小数部分の長さが0のとき、言い替えると整数部分の長さが9のときはなにも削られないことになります。これは9桁すべてが整数部分のとき、つまり桁溢れを起こしたときに電卓の表示窓にエラー表示*4をしなければならないからです。
こうしておくと足し算の結果を受け取(って表示す)る側は、桁溢れがあったかどうかを整数部分の長さで知ることができるわけです。

addを用いていくつか計算してみましょう。

(define rate-common (make-num " " '(2 7 6) '(4 8 7 5)))
(define rate-leap   (make-num " " '(8 8) '(7 5 5)))
"276.4875 + 88.755"
(add rate-common rate-leap)
(define today (make-num " " '(2 0 1 9) '(1 1 0 8)))
(define thelastday (make-num " " '(9 9 9 9 9) '(9 9 9)))
"2019.1108 + 99999.999"
(add today thelastday)
(define today (make-num " " '(2 0 1 9 1 1 0) '(8)))
(define thelastday (make-num " " '(9 9 9 9 9 9 9 9) '()))
"2019110.8 + 99999999"
(add today thelastday)

f:id:brv00:20191108200129j:plain:w250

桁溢れを起こさない計算の結果は8桁に切り詰められ(末尾が0ならさらに切り詰められ)*5、桁溢れを起こす計算の結果は9桁になっています。

続いて引き算関数です。絶対値の大きい数から小さい数を引くのでまず比較関数を定義します。

(define (l<? xs ys)
  (and (cons? xs)
       (or (< (car xs) (car ys))
           (and (= (car xs) (car ys)) (l<? (cdr xs) (cdr ys))))))

減数と被減数を入れ替えると答えの符号が変わるので符号を変える関数もあるとよいです*6

(define (neg sign) (if (string=? sign " ") "-" " "))

で、こちらが引き算関数です。名前はsubです。

(define (sub x y)
  (if (string=? (num-sign x) (num-sign y))
      (let*-values (((li xs ys) (align x y))
                    ((sign zs) (if (l<? xs ys)
                                   (values (neg (num-sign x)) (cdr (lop - ys xs)))
                                   (values      (num-sign x)  (cdr (lop - xs ys)))))
                    ((i) (drop0s (take zs li))))
        (make-num sign (if (null? i) '(0) i) (rdrop0s (drop zs li)))))
      (add x (make-num (num-sign x) (num-i y) (num-f y))))

これを使って引き算をしてみましょう。

"1 - 0.2857142"
(sub (make-num " " '(1) '()) (make-num " " '(0) '(2 8 5 7 1 4 2)))
"12.345678 - 9.3456778"
(sub (make-num " " '(1 2) '(3 4 5 6 7 8)) (make-num " " '(9) '(3 4 5 6 7 7 8)))

f:id:brv00:20191108200302j:plain:w250

ちゃんと(?)9.3456778の最後の8が無視されていますね。

続きます。

(この記事のテストコード)

*1:何桁電卓かはmax-ndigitsの値で決まるのですがここでは8ということにしておきます。

*2:もともと8桁より桁数が小さいときには余分な0をくっつけているのですが、それがなくても2数の最後の桁が例えば5と5だったら足しても引いても計算結果の最後の桁は0になります。

*3:加算か減算かも引数で指定できるやつ。

*4:端のほうに小さくEって出るやつ。

*5:2つめの結果は実際には整数部分5桁、小数部分3桁の合計8桁に揃えてから足し算をして得ていますが、端数切り捨てでも同じ結果が得られます。この2つの方法の間で結果が変わるのは引き算のときです。

*6:1回しか使ってないので関数はなくてもよいかもです。