【Ruby】四則演算に対応した電卓を実装する方法

電卓の完成イメージ

>123+13
135.0
>123-1*9
114.0
>123/(1+9)+123456
123468.3

このように、対話形式で数式を受け付けてその計算結果を出力する電卓を作ります。

前提知識

数式はノードとエッジで表現する

今回、数式は配列として表現します。

[:add, 1, 2] # 1+2
[:mul, [:add, 1, 2], 3] # (1+2)\*3

例えば1+2(1+2)\*3の場合、このように配列に変換して考えます。

構文解析で四則演算を考える

四則演算の優先順位は「()内の計算→掛け算・割り算→足し算→引き算」ですよね。なので配列に変換する際にそれを考慮する必要があります。

そんな配列への変換(構文解析)をするために、expressiontermfactorという3つの構文に置き換えて考え、それぞれをメソッドとして実装します。

そしてそれぞれではその配列の3つの要素を見て以下のように判断していきます。

構文詳細
expression足し算か引き算をしていればterm
term掛け算か割り算をしていればfactor
factor()があればtermへ。無ければその数値を返す

これだけだと分かりにくい思うので具体例を挙げるとこんな感じ。

1+(2-3)*4
→term+term
→factor+term
→1+term
→1+factor*facter
→1+(expression)*facter
→1+(term-term)*facter
→1+(facter-term)*facter
→1+(2-term)*facter
→1+(2-facter)*facter
→1+(2-3)*facter
→1+(2-3)\*4

まだ漠然としているかもしれませんが、実際に書いていった方が分かりやすいと思うので本題に移っていきましょう。

Rubyで四則演算に対応した電卓を実装する方法

1. StringScannerを使って字句解析

require "strscan"
def analysis
if @scanner.scan(/\d+|[\+\-\*\/()]/) # 数字または記号かどうか
if @scanner[0] =~ /[\+\-\*\/()]/ # 符号の場合
@@tokens[@scanner[0]]
else
@scanner[0]
end
else
nil
end
end

まず、そのトークンが数値なのか演算子なのかを判定するanalysisメソッドを実装します。

StringScannerクラスのインスタンスに対してscanメソッドを使って、数値もしくは演算子だった場合にそれを返すようにしています。

参考:class StringScanner (Ruby 3.2 リファレンスマニュアル)

2. expression・term・factorで構文解析

def expression
result = term()
token = analysis()
while token == :add || token == :sub #足し算か引き算の場合
result = [token, result, term()]
token = analysis()
end
reverse_analysis() #判定のために1回多くanalysisを呼んでいるため戻す
return result
end
def term
result = factor()
token = analysis()
while token == :mul || token == :div #掛け算か割り算の場合
result = [token, result, factor()]
token = analysis()
end
reverse_analysis() #判定のために1回多くanalysisを呼んでいるため戻す
return result
end
def factor
token = analysis()
if token =~ /\d+/ # リテラルの場合
result = token.to_f # ここで実数に変換
elsif token == :lpar
result = expression() # 単なる配列の形にしたいのでexpressionを呼び出すだけ
analysis() # 閉じ括弧をスキップ
else
raise Exception,"エラーです"
end
return result
end

前提で触れたexpressiontermfactorの3つを実装。

expressionでは足し算か引き算がある限り、配列の中に配列(`term)を格納していき最終的にその配列を返します。

termでも掛け算か割り算がある限り、配列の中に配列(factor)を格納していき最終的にその配列を返します。

factorでは字句解析で得られたものが1つの数値の場合はそれを返し、開き括弧の場合はexpressionを呼び出します。

def reverse_analysis
if !(@scanner.eos?) #「previous match record not exist」の対処。トークンが末尾にいってるとエラーになる。
@scanner.unscan()
end
end

その3つのメソッドの中では符号の判定のためにanalysisメソッドを呼び出しているのですが、そのままだとトークンが1つ前にずれてしまうので、それを防ぐためにトークンを1つ前に戻すreverse_analysisメソッドも作っておきます。

3. 計算結果を返すevalメソッドを実装

@@tokens = {
'+' => :add,
'-' => :sub,
'*' => :mul,
'/' => :div,
'(' => :lpar,
')' => :rpar
}
def eval(exp)
if exp.instance_of?(Array) #配列以外は弾く
case exp[0]
when :add
return eval(exp[1]) + eval(exp[2])
when :sub
return eval(exp[1]) - eval(exp[2])
when :mul
return eval(exp[1]) * eval(exp[2])
when :div
return eval(exp[1]) / eval(exp[2])
end
else
return exp
end
end

そして計算用に変換した配列を実際に計算してくれるevalメソッドを実装。

符号それぞれをシンボルに割り当てることによって、返す計算結果を変えます。

4. evalメソッドを呼び出す

def initialize
while true do
print ">"
input = STDIN.gets
if input == "q\n"
p "終了します"
exit
end
@scanner = StringScanner.new(input.chomp)
p eval(expression) # 結果を出力
end
end

最後にexpressionで返ってきたものを引数としてevalを呼び出すコンストラクタを実装してあげれば、完成です 🎉

以下に全体のコードを載せておきます。

require "strscan"
class Calc
@@tokens = {
'+' => :add,
'-' => :sub,
'*' => :mul,
'/' => :div,
'(' => :lpar,
')' => :rpar
}
def analysis
if @scanner.scan(/\d+|[\+\-\*\/()]/) # 数字または記号かどうか
if @scanner[0] =~ /[\+\-\*\/()]/ # 符号の場合
@@tokens[@scanner[0]]
else
@scanner[0]
end
else
nil
end
end
def eval(exp)
if exp.instance_of?(Array)
case exp[0]
when :add
return eval(exp[1]) + eval(exp[2])
when :sub
return eval(exp[1]) - eval(exp[2])
when :mul
return eval(exp[1]) * eval(exp[2])
when :div
return eval(exp[1]) / eval(exp[2])
end
else
return exp
end
end
def reverse_analysis
if !(@scanner.eos?) #「previous match record not exist」の対処。トークンが末尾にいってるとエラーになる。
@scanner.unscan()
end
end
def expression
result = term()
token = analysis()
while token == :add || token == :sub # 足し算か引き算の場合
result = [token, result, term()]
token = analysis()
end
reverse_analysis()
return result
end
def term
result = factor()
token = analysis()
while token == :mul || token == :div # 掛け算か割り算の場合
result = [token, result, factor()]
token = analysis()
end
reverse_analysis()
return result
end
def factor
token = analysis()
if token =~ /\d+/ # リテラルの場合
result = token.to_f # ここで実数に変換
elsif token == :lpar
result = expression() # 単なる配列の形にしたいのでexpressionを呼び出すだけ
analysis() # 閉じ括弧をスキップ
else
raise Exception,"エラーです"
end
return result
end
def initialize
while true do
print ">"
input = STDIN.gets
if input == "q\n"
p "終了します"
exit
end
@scanner = StringScanner.new(input.chomp)
p eval(expression) # 結果を出力
end
end
end
Calc.new