C#
正規表現
Unity

C#の正規表現を使って文字列の計算式を計算する


文字列の計算について

文字列を計算して結果を出力する方法はいくつかありますが、今回は正規表現を使って計算式を解析してみたいと思います。

実現したいことは次のようなものになります。


  • 四則演算ができる

  • 括弧付きの計算ができる

  • 関数が使える


確認環境

Unity 2018.3.5f1

IL2CPP

Android


実装

実装の全容です。

解説は後述します。


文字列計算クラス(本体)


StringCalculation.cs

using System;

using System.Text.RegularExpressions;

public static class StringCalculation
{
// 有効桁数
public const int SIGNIFICANT_DIGIT = 16;

// 小数点も取得する数値を表すPattern
private const string DECIMAL = @"[\+\-]?\d+(?:\.\d+)?";

// 文字列を計算して結果を返す
public static string Calculation(string calcStr)
{
string result = "";

// 対象文字列の半角スペースを削除
result = Regex.Replace(calcStr, @"\s", "");

// +-の符号重複を整理
result = OperatorOrganize(result);

// 余分な括弧を整理する
result = BracketsOrganize(result);

// 関数の計算
result = ProcFunction(result);

// ()内を計算
result = CalculationBrackets(result);

// カッコが全て計算された後の通常計算
result = NormalCalculation(result);

// 先頭の + は削除する
result = Regex.Replace(result, @"^\+", "");

return result;
}

// + または - 演算子の重複を解消する
private static string OperatorOrganize(string calcStr)
{
// + または - が2個連続している箇所を検出
string pattern = @"[\+\-]{2}";

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
string result;

// 2つ続いている演算子を取得
Match match = Regex.Match(baseMatch.Value, @"([\+\-])([\+\-])");

result = (match.Result("$1") == match.Result("$2")) ? "+" : "-";

return result;
});
}

// 余計な括弧をまとめる
private static string BracketsOrganize(string calcStr)
{
string d = @"[\+\-\*\/]|" + DECIMAL;
string pattern = @"\({2}((?:" + d + @"|\((?:" + d + @")+\))+)\){2}";

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
string result = baseMatch.Result("($1)");
return result;
});
}

// 関数の計算
private static string ProcFunction(string calcStr)
{
// Sin() や Cos() など関数の形に一致
string d = @"[\+\-\*\/]|" + DECIMAL;
string d1 = d + @"\,?";
string d2 = @"\((?:" + d + @")+\)\,?";
string f = @"[a-zA-Z_]\w+";
string pattern = "(" + f + @")\(((?:" + d1 + "|" + d2 + @")*)\)";

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
// 後ろで正規表現を使用するので、$1が消える前に関数名を取得しておく
string func = baseMatch.Result("$1");

// 引数をカンマ区切りで取得し、それぞれを計算する
string[] args = baseMatch.Result("$2").Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < args.Length; i++)
{
args[i] = CalculationBrackets(args[i]);
args[i] = NormalCalculation(args[i]);
}

// 関数呼び出し
string result = StringCalculationFunctions.InvokeMethod(func, args);

// 小数点第n位まで取得し、後ろの余分な0を切り捨てる
result = result.ToDouble().ToStringWithDigit(SIGNIFICANT_DIGIT);

return result;
});
}

// カッコ付きの箇所の計算
private static string CalculationBrackets(string calcStr)
{
// 一番内側の括弧に一致
string d = DECIMAL;
string pattern = @"\(((?:[\-\+\*\/]|" + d + @")+)\)";

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
string result = baseMatch.Result("$1");
result = OperatorOrganize(result);
result = NormalCalculation(result);

return result;
});
}

// カッコなしの通常の計算を行う
private static string NormalCalculation(string calcStr)
{
// 掛け算と割り算
string result = MultiDiv(calcStr);

// 足し算と引き算
result = AddSub(result);

return result;
}

// * または / の計算を行う
private static string MultiDiv(string calcStr)
{
// 掛け算、または割り算に一致
string d = DECIMAL;
string pattern = d + @"\*" + d + "|" + d + @"\/" + d;

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
double nResult = 0;

// 演算子で分割し、計算
Match match = Regex.Match(baseMatch.Value, @"\*|\/");

string l = match.Result("$`");
string r = match.Result("$'");

if (match.Value == "*")
{
nResult = (l.ToDouble() * r.ToDouble());
}
else if (match.Value == "/")
{
nResult = (l.ToDouble() / r.ToDouble());
}
else
{
nResult = match.Value.ToDouble();
}

// 小数点第n位まで取得し、後ろの余分な0を切り捨てる
string result = nResult.ToStringWithDigit(SIGNIFICANT_DIGIT);

// 計算結果が + の場合、前方の文字と結合する際に符号なしでくっついてしまう※ので、符号を追加
// ※この関数の結果が10、前方の文字が123だとした場合、123 10 => 12310 になってしまう。
//  + を記述しておけば、 123 +10 => 123+10 と正しい数式の形に戻せる。
string prefix = nResult >= 0 ? "+" : "";

return prefix + result;
});
}

// + または - の計算を行う
private static string AddSub(string calcStr)
{
// 足し算、または引き算に一致
string d = DECIMAL;
string pattern = d + @"\+" + d + "|" + d + @"\-" + d;

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
double nResult = 0;

// 演算子で分割し、計算
Match match = Regex.Match(baseMatch.Value, @"(" + d + @")([\+\-])(" + d + @")");
string l = match.Result("$1");
string r = match.Result("$3");
string op = match.Result("$2");

if (op == "+")
{
nResult = (l.ToDouble() + r.ToDouble());
}
else if (op == "-")
{
nResult = (l.ToDouble() - r.ToDouble());
}
else
{
nResult = match.Value.ToDouble();
}

// 小数点第n位まで取得し、後ろの余分な0を切り捨てる
string result = nResult.ToStringWithDigit(SIGNIFICANT_DIGIT);

return result;
});
}

// 各種計算用メソッドのベース
private static string CalculationBase(string calcStr, string pattern, Func<Match, string> callback, Match argMatch = null)
{
// 指定したパターンに一致する
Match match = argMatch ?? Regex.Match(calcStr, pattern);
if (match.Success)
{
// あとで結合するので、計算する文字列の前後を取得しておく
string pre = match.Result("$`");
string suf = match.Result("$'");

// 計算はコールバックに任せる
string result = callback(match);

// 計算結果を元の場所に結合
result = string.Format("{0}{1}{2}", pre, result, suf);

// まだ計算するべき数式があるかチェック
Match nMatch = Regex.Match(result, pattern);
if (nMatch.Success)
{
result = CalculationBase(result, pattern, callback, nMatch);
}

return result;
}

return calcStr;
}
}



処理用関数定義&呼び出し用クラス


StringCalculationFunction.cs

using System;

using System.Collections.Generic;
using System.Linq;

public static class StringCalculationFunctions
{
// ネイピア数
private const double E = 2.71828182845904523536;

// 有効桁数
private const int SIGNIFICANT_DIGIT = StringCalculation.SIGNIFICANT_DIGIT;

// メソッド呼び出し用辞書
private static readonly Dictionary<(string, int), Func<string[], string>> _methodCache = new Dictionary<(string, int), Func<string[], string>>
{
{("Sin", new Type[]{typeof(string)}.GetValueHash()), (args)=>{ return Sin(args[0]); }},
{("Cos", new Type[]{typeof(string)}.GetValueHash()), (args)=>{ return Cos(args[0]); }},
{("Tan", new Type[]{typeof(string)}.GetValueHash()), (args)=>{ return Tan(args[0]); }},
{("Log10", new Type[]{typeof(string)}.GetValueHash()), (args)=>{ return Log10(args[0]); }},
{("Log", new Type[]{typeof(string)}.GetValueHash()), (args)=>{ return Log(args[0]); }},
{("Log", new Type[]{typeof(string), typeof(string)}.GetValueHash()), (args)=>{ return Log(args[0], args[1]); }},
{("Ln", new Type[]{typeof(string)}.GetValueHash()), (args)=>{ return Ln(args[0]); }},
};

// Type[]で同じ並びの際に同じハッシュ値を返す
private static int GetValueHash(this Type[] types)
{
int result = 0;
int length = types.Length;
for (int i = 0; i < length; i++)
{
var shift_l = (i % 32);
var shift_r = 32 - shift_l;
var hash = types[i].GetHashCode();
result += hash << shift_l | hash >> shift_r;
}
return result;
}

// 登録されたメソッドを呼び出す
public static string InvokeMethod(string methodName, params object[] args)
{
var argTypesHash = ((args == null) ? Type.EmptyTypes : args.Select(x => x.GetType()).ToArray()).GetValueHash();
var cacheKey = (methodName, argTypesHash);
var func = _methodCache[cacheKey];
return func(args);
}

//====================================================================================================
// 実体メソッド
//====================================================================================================

// Sin
public static string Sin(string calcStr)
{
double result = Math.Sin(Deg2rad(calcStr.ToDouble()));
return result.ToStringWithDigit(SIGNIFICANT_DIGIT);
}

// Cos
public static string Cos(string calcStr)
{
double result = Math.Cos(Deg2rad(calcStr.ToDouble()));
return result.ToStringWithDigit(SIGNIFICANT_DIGIT);
}

// Tan
public static string Tan(string calcStr)
{
double result = Math.Tan(Deg2rad(calcStr.ToDouble()));
return result.ToStringWithDigit(SIGNIFICANT_DIGIT);
}

// log - 10を底としたlogの計算を行う
public static string Log10(string calcStr)
{
double result = Math.Log10(calcStr.ToDouble());
return result.ToStringWithDigit(SIGNIFICANT_DIGIT);
}

// log - ネイピア数 e を底としたlogの計算を行う
public static string Log(string calcStr)
{
double result = Math.Log(calcStr.ToDouble());
return result.ToStringWithDigit(SIGNIFICANT_DIGIT);
}

// log - 自前でベース値を設定する
public static string Log(string calcStr, string baseStr)
{
double result = Math.Log(calcStr.ToDouble(), baseStr.ToDouble());
return result.ToStringWithDigit(SIGNIFICANT_DIGIT);
}

// log - ネイピア数 e を底としたlogの計算を行う
public static string Ln(string calcStr)
{
double result = Math.Log(calcStr.ToDouble(), E);
return result.ToStringWithDigit(SIGNIFICANT_DIGIT);
}

// 角度をラジアンに変換
private static double Deg2rad(double num)
{
return num / 180.0 * Math.PI;
}
}



string⇔doubleの変換用拡張メソッド


DoubleExtension.cs

using System.Text.RegularExpressions;

public static class DoubleExtension
{
// 小数点第n位まで取得し、後ろの余分な0を切り捨てて文字列にする
public static string ToStringWithDigit(this double value, int digit)
{
// 小数点第digit位まで取得し、後ろの余分な0を切り捨てる
string result = string.Format("{0:f" + digit + "}", value);
return Regex.Replace(result, @"(\.?0+)$", "");
}
}



StringExtension.cs

using System;

public static class StringExtension
{
// 文字列をdouble型に変換します
public static double ToDouble(this string str)
{
if (double.TryParse(str, out double ret))
{
return ret;
}

throw new ArgumentException("<" + str + ">をdouble型に変換できません");
}
}



解説

それぞれのメソッドの役割について解説していきます。



パターンマッチング用基底メソッド

まずはパターンマッチング用の共通処理基底メソッドです。

解析したい文字列、正規表現パターンを引数で受け取り、パターンにマッチした場合はそのマッチした部分の処理をコールバックに任せます。

コールバックにて処理された文字列は元の場所に再配置され、その結果の文字列内に再度マッチするパターンが存在する場合、再帰処理にてもう一度解析を行います。

再帰処理は指定されたパータンに一致しなくなるまで続きます。

1+2+3+4 という計算式と、足し算を処理するパターン(例えば n+m にマッチするパターン)が引数に与えられた場合の動作はこのようなイメージです。

[1+2]+3+43+3+4[3+3]+46+4[6+4]10 ⇒ n+mのパターンに一致しないので再帰処理終了。

ソースコード

private static string CalculationBase(string calcStr, string pattern, Func<Match, string> callback, Match argMatch = null)

{
// 指定したパターンに一致する
Match match = argMatch ?? Regex.Match(calcStr, pattern);
if (match.Success)
{
// あとで結合するので、計算する文字列の前後を取得しておく
string pre = match.Result("$`");
string suf = match.Result("$'");

// 計算はコールバックに任せる
string result = callback(match);

// 計算結果を元の場所に結合
result = string.Format("{0}{1}{2}", pre, result, suf);

// まだ計算するべき数式があるかチェック
Match nMatch = Regex.Match(result, pattern);
if (nMatch.Success)
{
result = CalculationBase(result, pattern, callback, nMatch);
}

return result;
}

return calcStr;
}




数値の正規表現パターン

クラスの先頭辺りに記述されているこれについて。

private const string DECIMAL = @"[\+\-]?\d+(?:\.\d+)?";

このパターンは 123 +123 +123.45 -123 -123.45 といった文字列にマッチします。

よく使うので定数として定義しています。

+符号もマッチパターンに入れているのは、計算途中で 123-+456 のような文字列になる可能性があり、それを [123] - [+456] のように分解して計算したいためです。



無駄な演算子の重複処理メソッド

++ +- -+ -- といった重複したプラスとマイナスの符号を一つの符号にまとめます。

これは計算として無駄を省くのと、あらかじめ三重以上の演算子の重複を統合しておかないとパターンマッチングで処理できないためです。

例えば 4+-5 のような計算式を、4-5 のような形にまとめます。

マッチした2つの符号が同じ場合は+、違う場合は-に変換されます。

ソースコード

private static string OperatorOrganize(string calcStr)

{
// + または - が2個連続している箇所を検出
string pattern = @"[\+\-]{2}";

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
string result;

// 2つ続いている演算子を取得
Match match = Regex.Match(baseMatch.Value, @"([\+\-])([\+\-])");

result = (match.Result("$1") == match.Result("$2")) ? "+" : "-";

return result;
});
}




足し算、引き算処理メソッド

このメソッドでは [数値パターン]+[数値パターン] もしくは [数値パターン]-[数値パターン] といった文字列にマッチします。

マッチした文字列は、左辺、オペレータ、右辺に分解され、オペレータの符号によって実際の計算式を分岐しています。

計算結果は文字列に変換され、基底メソッドに返されます。

ソースコード

private static string AddSub(string calcStr)

{
// 足し算、または引き算に一致
string d = DECIMAL;
string pattern = d + @"\+" + d + "|" + d + @"\-" + d;

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
double nResult = 0;

// 演算子で分割し、計算
Match match = Regex.Match(baseMatch.Value, @"(" + d + @")([\+\-])(" + d + @")");
string l = match.Result("$1");
string r = match.Result("$3");
string op = match.Result("$2");

if (op == "+")
{
nResult = (l.ToDouble() + r.ToDouble());
}
else if (op == "-")
{
nResult = (l.ToDouble() - r.ToDouble());
}
else
{
nResult = match.Value.ToDouble();
}

// 小数点第n位まで取得し、後ろの余分な0を切り捨てる
string result = nResult.ToStringWithDigit(SIGNIFICANT_DIGIT);

return result;
});
}




掛け算、割り算処理メソッド

このメソッドでは [数値パターン]*[数値パターン] もしくは [数値パターン]/[数値パターン] といった文字列にマッチします。

加減メソッドとほぼ一緒ですが、加減メソッドより先に計算が行われる&数値パターンが+符号を含んでマッチされるため、計算結果が正の数の場合は必ず+符号を付け加えます。

それにより次のようなケースの問題を解消します。


  • 計算結果に符号を付けない場合 : 1+2*31[+2*3]16

  • 計算結果に符号を付ける場合  : 1+2*31[+2*3]1+6

ソースコード

private static string MultiDiv(string calcStr)

{
// 掛け算、または割り算に一致
string d = DECIMAL;
string pattern = d + @"\*" + d + "|" + d + @"\/" + d;

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
double nResult = 0;

// 演算子で分割し、計算
Match match = Regex.Match(baseMatch.Value, @"\*|\/");

string l = match.Result("$`");
string r = match.Result("$'");

if (match.Value == "*")
{
nResult = (l.ToDouble() * r.ToDouble());
}
else if (match.Value == "/")
{
nResult = (l.ToDouble() / r.ToDouble());
}
else
{
nResult = match.Value.ToDouble();
}

// 小数点第n位まで取得し、後ろの余分な0を切り捨てる
string result = nResult.ToStringWithDigit(SIGNIFICANT_DIGIT);

// 計算結果が + の場合、前方の文字と結合する際に符号なしでくっついてしまう※ので、符号を追加
// ※この関数の結果が10、前方の文字が123だとした場合、123 10 => 12310 になってしまう。
//  + を記述しておけば、 123 +10 => 123+10 と正しい数式の形に戻せる。
string prefix = nResult >= 0 ? "+" : "";

return prefix + result;
});
}




四則演算

四則演算については必ず同タイミングで計算が行われるはずなのでメソッドとしては一つにまとめます。

ソースコード

private static string NormalCalculation(string calcStr)

{
// 掛け算と割り算
string result = MultiDiv(calcStr);

// 足し算と引き算
result = AddSub(result);

return result;
}




括弧内の計算

一重括弧で括られた 四則演算記号 数値パターン を検出し、その結果を符号整理 ⇒ 四則演算の順に処理します。

要は四則演算前の最優先数式を処理するためのものです。

このメソッドも括弧の内部に括弧が存在する場合はパターンにマッチしないため、結果として一番内側の括弧から処理されるようになっています。

ソースコード

private static string CalculationBrackets(string calcStr)

{
// 一番内側の括弧に一致
string d = DECIMAL;
string pattern = @"\(((?:[\-\+\*\/]|" + d + @")+)\)";

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
string result = baseMatch.Result("$1");
result = OperatorOrganize(result);
result = NormalCalculation(result);

return result;
});
}




無駄な括弧の省略処理メソッド

関数処理のための事前処理メソッドです。

二重の括弧に挟まれた、四則演算記号 数値パターン 一重の括弧に挟まれた四則演算記号または数値パターン のみを検出し、その一致したパターンを一重の括弧で括ります。

二重括弧の中に二重括弧が存在する場合は外側の二重括弧には反応しない(つまり一番内側の二重括弧に反応する)ようになっています。

(([マッチパターン]))([マッチパターン])

二重括弧内に関数がある場合も処理されませんが、このメソッドの目的が Sin((90)) といった関数内の二重括弧を排除するのが目的なので問題ありません。

後述しますが、関数は必ず Function([数式]) の形でパターンマッチさせるためです。

関数が全て計算された後の多重括弧については、CalculationBracketsメソッドが内側から一つずつ処理してくれるので問題ありません。

ソースコード

private static string BracketsOrganize(string calcStr)

{
string d = @"[\+\-\*\/]|" + DECIMAL;
string pattern = @"\({2}((?:" + d + @"|\((?:" + d + @")+\))+)\){2}";

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
string result = baseMatch.Result("($1)");
return result;
});
}




関数処理メソッド

Function(数式, 数式, ...) のパターンを検出し、引数になる数式はそれぞれ数値に変換できるレベルまで計算されます。

関数名と引数リストは、関数処理専用のクラスによって計算されます。

関数名は、アルファベットとアンダースコアからしか開始できないようになっています(数値から始まる関数名はNG)。

また、Function((数式)) のような二重括弧以上のパターンは対応していないため、あらかじめ無駄な括弧を省いておく必要があります(前述参照)。

定義すれば引数なしの関数も呼び出すことができます。

ソースコード

private static string ProcFunction(string calcStr)

{
// Sin() や Cos() など関数の形に一致
string d = @"[\+\-\*\/]|" + DECIMAL;
string d1 = d + @"\,?";
string d2 = @"\((?:" + d + @")+\)\,?";
string f = @"[a-zA-Z_]\w+";
string pattern = "(" + f + @")\(((?:" + d1 + "|" + d2 + @")*)\)";

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
// 後ろで正規表現を使用するので、$1が消える前に関数名を取得しておく
string func = baseMatch.Result("$1");

// 引数をカンマ区切りで取得し、それぞれを計算する
string[] args = baseMatch.Result("$2").Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < args.Length; i++)
{
args[i] = CalculationBrackets(args[i]);
args[i] = NormalCalculation(args[i]);
}

// 関数呼び出し
string result = StringCalculationFunctions.InvokeMethod(func, args);

// 小数点第n位まで取得し、後ろの余分な0を切り捨てる
result = result.ToDouble().ToStringWithDigit(SIGNIFICANT_DIGIT);

return result;
});
}




関数定義&呼び出しクラス (class StringCalculationFunctions)

このクラスは数式内に記述された関数を処理するための実体と、呼び出すための仕組みを実装しています。

関数の実体を記述し、それを辞書に登録することで呼び出しが可能になります。

(例)実体を記述

public static string Log(string calcStr)

{
double result = Math.Log(calcStr.ToDouble());
return result.ToStringWithDigit(SIGNIFICANT_DIGIT);
}


(例)辞書に登録

private static readonly Dictionary<(string, int), Func<string[], string>> _methodCache = new Dictionary<(string, int), Func<string[], string>>

{
~~~
{("Log", new Type[]{typeof(string)}.GetValueHash()), (args)=>{ return Log(args[0]); }},
~~~
};


辞書のキーはTupleで、<string 呼び出し名, int 引数の型リストのハッシュ値> となっています。

引数の型リストのハッシュ値は独自計算によって、引数の個数は同じで型だけが違うメソッドがオーバーロードされていても問題ないようになっています(今回はすべてstringなのであまり意味ありませんが)。

恐らくこのクラスの実装形式からしてstring以外の引数はありえないので、Tupleキーのint値は単純に引数の数(Length)でも良いかと思います。

引数型リストのハッシュ値生成コード

private static int GetValueHash(this Type[] types)

{
int result = 0;
int length = types.Length;
for (int i = 0; i < length; i++)
{
var shift_l = (i % 32);
var shift_r = 32 - shift_l;
var hash = types[i].GetHashCode();
result += hash << shift_l | hash >> shift_r;
}
return result;
}




もしくは

引数型リストのハッシュ値生成コード

private static int GetValueHash(this Type[] types)

{
return types.Length;
}




 


余談

実は動的呼び出しのために最初Expressionを使用していたのですが、UnityのIL2CPPでビルドした場合にCompile()が使えずにエラーになってしまったため、同じ形式の処理として事前定義をしておくことで使えるようにしています。

Expressionの動的コード生成と比べると辞書への登録の一手間が増えますが、大した手間ではないので実体のメソッドを作成した後に辞書登録さえ忘れなければ特に問題ないかと思います。



処理順序

実装された各メソッドを使って、次の順序で処理を行います。


  1. +-の符号を整理

  2. 関数処理のために余分な括弧を整理

  3. 関数の計算

  4. 括弧で括られた式の計算

  5. 括弧がすべて計算されたので、通常の四則演算

これについては通常の計算優先度と同じ感じになってます。

半角スペースについては正規表現の邪魔になるのであらかじめ削除しておきます。

ソースコード

public static string Calculation(string calcStr)

{
string result = "";

// 対象文字列の半角スペースを削除
result = Regex.Replace(calcStr, @"\s", "");

// +-の符号重複を整理
result = OperatorOrganize(result);

// 余分な括弧を整理する
result = BracketsOrganize(result);

// 関数の計算
result = ProcFunction(result);

// ()内を計算
result = CalculationBrackets(result);

// カッコが全て計算された後の通常計算
result = NormalCalculation(result);

// 先頭の + は削除する
result = Regex.Replace(result, @"^\+", "");

return result;
}



検算

色々なパターンの数式を用いて検算を行います。

文字列の数式と、実際のコード上で計算した結果を比べ、一定の誤差以内であればOKとしています。

適当なGameObjectにアタッチして実行したらGameView上で結果が見れます。

検証ソースコード

using System;

using UnityEngine;

public class StrCalcTest : MonoBehaviour
{
string _dbgStr = "";
private Vector2 _scrollPosition;

// Use this for initialization
void Start()
{
// 通常計算
_Recalculation(
"3.5 - 0.5 + 21 * 2 / 2.5",
3.5 - 0.5 + 21 * 2 / 2.5
);

// 通常計算
_Recalculation(
"-2 + 1 * +2 / -2 / 2.0 * -2 + 5 * 2",
-2 + 1 * +2 / -2 / 2.0 * -2 + 5 * 2
);

// 括弧付き計算
_Recalculation(
"(1 + 2.5 + (2 - 3.5) * (1 + 1)) * 5 + 3 * (20 - 8)",
(1 + 2.5 + (2 - 3.5) * (1 + 1)) * 5 + 3 * (20 - 8)
);

// 演算子重複
_Recalculation(
"1 * (+2 + (-3)) - -+4 - -+ +- -5",
1 * (+2 + (-3)) - -+4 - -+ +- -5
);

// 括弧の計算
_Recalculation(
"2 - (1 * -11)",
2 - (1 * -11)
);

// 括弧の計算
_Recalculation(
"1 * (+2 + (-3)) - (-(+4)) - (-(+(+(-(-5)))))",
1 * (+2 + (-3)) - (-(+4)) - (-(+(+(-(-5)))))
);

// 括弧の計算
_Recalculation(
"((-20.25) + 4) / ((4 / 2) + 3 * (1 * 2))",
((-20.25) + 4) / ((4 / 2) + 3 * (1 * 2))
);

// 関数の計算
_Recalculation(
"Sin(45)",
Math.Sin(Deg2rad(45))
);

// 関数の計算
_Recalculation(
"Cos(45)",
Math.Cos(Deg2rad(45))
);

// 関数の計算
_Recalculation(
"Tan(45)",
Math.Tan(Deg2rad(45))
);

// 関数の計算
_Recalculation(
"Cos((12)) + Tan((179) + Sin(75))",
Math.Cos(Deg2rad((12))) + Math.Tan(Deg2rad((179) + Math.Sin(Deg2rad(75))))
);

// 余分な括弧の計算
_Recalculation(
"((((-(2 + 3) + 9))))",
((((-(2 + 3) + 9))))
);

// 関数と過剰な括弧の計算
_Recalculation(
"((Cos(Sin(( Cos(((((45+45)))) + 90) * - 90 )) * 180)) + 20 + Tan((180)))",
((Math.Cos(Deg2rad(Math.Sin((Deg2rad(Math.Cos(Deg2rad(((((45 + 45)))) + 90)) * -90))) * 180))) + 20 + Math.Tan(Deg2rad((180))))
);

// 関数と過剰な括弧の計算
_Recalculation(
"Cos((((((((-(2+3)+9))))))))",
Math.Cos(Deg2rad((((((((-(2 + 3) + 9)))))))))
);

// Log計算
_Recalculation(
"Sin(Log10(10))",
Math.Sin(Deg2rad(Math.Log10(10)))
);

// Log計算
_Recalculation(
"Sin(Log(10))",
Math.Sin(Deg2rad(Math.Log(10)))
);

// Log計算
_Recalculation(
"Sin(Log(10+2*3, 4*2.4-5))",
Math.Sin(Deg2rad(Math.Log(10 + 2 * 3, 4 * 2.4 - 5)))
);

// Log計算
_Recalculation(
"Log10(0.1)",
Math.Log10(0.1)
);

// Log計算
_Recalculation(
"Ln(42.22*2)",
Math.Log(42.22 * 2, Math.E)
);
}

private void OnGUI()
{
_scrollPosition = GUILayout.BeginScrollView(_scrollPosition);
GUILayout.Label(_dbgStr);
GUILayout.EndScrollView();
}

private static double Deg2rad(double num)
{
return num / 180.0 * Math.PI;
}

// 文字列の式と実際に計算した際の数値結果を入力し、検算を行う
private void _Recalculation(string str, double nResult)
{
string sResult = StringCalculation.Calculation(str);

// 計算結果が文字列として一致、もしくは誤差の範囲内かどうか
string isSuccess = (sResult == nResult.ToString() || Math.Abs(sResult.ToDouble() - nResult) <= 0.000000000001) ? "○" : "×";
string resultStr = "検算結果 : " + "" + isSuccess + "\n" + str + " =>\n STR : [" + sResult + "]\n NUM : [" + nResult + "]\n\n";

_dbgStr += resultStr;
}
}



最後に

若干の誤差は出るものの、大体一致する程度の計算ができました。

もう少し精度を上げるならdoubleではなくdecimalを使ったら良いんでしょうか。

正規表現に精通している訳ではないのでもっと簡単な書き方もあるかもしれません。


おまけ

処理途中のログを出力する機能を実装したコードを置いておきます。

内容としてはログなしのコードと変わりません。

Unity上で実行すると、マッチング結果とそれをどのように処理しているかの過程を見ることができます。

※出力先はUnityのConsoleビューです。

ソースコード


StringCalculation.cs

#define CALC_LOG_OUTPUT


using System;
using System.Text.RegularExpressions;

#if CALC_LOG_OUTPUT
using UnityEngine;
#endif

namespace MyEngine
{
/// <summary>
/// 文字列内の数式を計算する静的メソッドを提供します
/// </summary>
public static class StringCalculation
{

#if CALC_LOG_OUTPUT
// Debug用変数

private static string _logText = "";
private static string _indent = "";
private static string _procFuncName = "";
#endif

/// <summary>
/// 有効桁数
/// </summary>
public const int SIGNIFICANT_DIGIT = 16;

/// <summary>
/// 小数点も取得する数値を表すPattern
/// </summary>
private const string DECIMAL = "[\\+\\-]?\\d+(?:\\.\\d+)?";

/// <summary>
/// 文字列を計算して結果を返す
/// </summary>
/// <param name="calcStr"></param>
/// <returns></returns>
public static string Calculation(string calcStr)
{
string result = "";

#if CALC_LOG_OUTPUT
// Log
CalculationLog("入力 : " + calcStr + "\n");
#endif

// 対象文字列の半角スペースを削除
result = Regex.Replace(calcStr, @"\s", "");

#if CALC_LOG_OUTPUT
// Log
CalculationLog("数式 : " + result + "\n");
#endif

// +-の符号重複を整理
result = OperatorOrganize(result);

// 余分な括弧を整理する
result = BracketsOrganize(result);

// 関数の計算
result = ProcFunction(result);

// ()内を計算
result = CalculationBrackets(result);

// カッコが全て計算された後の通常計算
result = NormalCalculation(result);

// 先頭の + は削除する
result = Regex.Replace(result, @"^\+", "");

#if CALC_LOG_OUTPUT
// Log
CalculationLog("結果 : " + result + "\n");
Debug.Log(_logText);
_logText = string.Empty;
#endif

return result;
}

/// <summary>
/// + または - 演算子の重複を解消する
/// </summary>
/// <param name="calcStr"></param>
/// <returns></returns>
private static string OperatorOrganize(string calcStr)
{
#if CALC_LOG_OUTPUT
return IndividualProcBase(GetHostFuncName(), () =>
#endif
{
// + または - が2個連続している箇所を検出
string pattern = @"[\+\-]{2}";

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
string result;

// 2つ続いている演算子を取得
Match match = Regex.Match(baseMatch.Value, @"([\+\-])([\+\-])");

result = (match.Result("$1") == match.Result("$2")) ? "+" : "-";

return result;
});
}
#if CALC_LOG_OUTPUT
);
#endif
}

/// <summary>
/// 余計な括弧をまとめる
/// </summary>
/// <param name="calcStr"></param>
/// <returns></returns>
private static string BracketsOrganize(string calcStr)
{
#if CALC_LOG_OUTPUT
return IndividualProcBase(GetHostFuncName(), () =>
#endif
{
string d = @"[\+\-\*\/]|" + DECIMAL;
string pattern = @"\({2}((?:" + d + @"|\((?:" + d + @")+\))+)\){2}";

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
string result = baseMatch.Result("($1)");
return result;
});
}
#if CALC_LOG_OUTPUT
);
#endif
}

/// <summary>
/// 関数の計算
/// </summary>
/// <param name="calcStr"></param>
/// <returns></returns>
private static string ProcFunction(string calcStr)
{
#if CALC_LOG_OUTPUT
return IndividualProcBase(GetHostFuncName(), () =>
#endif
{
// Sin() や Cos() など関数の形に一致
string d = @"[\+\-\*\/]|" + DECIMAL;
string d1 = d + @"\,?";
string d2 = @"\((?:" + d + @")+\)\,?";
string f = @"[a-zA-Z_]\w+";
string pattern = "(" + f + @")\(((?:" + d1 + "|" + d2 + @")*)\)";

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
// 後ろで正規表現を使用するので、$1が消える前に関数名を取得しておく
string func = baseMatch.Result("$1");

// 引数をカンマ区切りで取得し、それぞれを計算する
string[] args = baseMatch.Result("$2").Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);

#if CALC_LOG_OUTPUT
string dArg = "";
#endif
for (int i = 0; i < args.Length; i++)
{
#if CALC_LOG_OUTPUT
CalculationLog("\n");
CalculationLog(" # " + args[i] + "\n");
#endif
args[i] = CalculationBrackets(args[i]);
args[i] = NormalCalculation(args[i]);

#if CALC_LOG_OUTPUT
dArg += args[i] + ",";
#endif
}

string result = StringCalculationFunctions.InvokeMethod(func, args);

#if CALC_LOG_OUTPUT
dArg = dArg.Substring(0, dArg.Length - 1);
CalculationLog("\n");
CalculationLog("--:" + func + "(" + dArg + ") => " + result + "\n");
CalculationLog("\n");
#endif

// 小数点第n位まで取得し、後ろの余分な0を切り捨てる
result = result.ToDouble().ToStringWithDigit(SIGNIFICANT_DIGIT);

return result;
});
}
#if CALC_LOG_OUTPUT
);
#endif
}

/// <summary>
/// カッコ付きの箇所の計算
/// </summary>
/// <param name="calcStr"></param>
/// <returns></returns>
private static string CalculationBrackets(string calcStr)
{
#if CALC_LOG_OUTPUT
return IndividualProcBase(GetHostFuncName(), () =>
#endif
{
// 一番内側の括弧に一致
string d = DECIMAL;
string pattern = @"\(((?:[\-\+\*\/]|" + d + @")+)\)";

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
string result = baseMatch.Result("$1");
result = OperatorOrganize(result);
result = NormalCalculation(result);

return result;
});
}
#if CALC_LOG_OUTPUT
);
#endif
}

/// <summary>
/// カッコなしの通常の計算を行う
/// </summary>
/// <param name="calcStr"></param>
/// <returns></returns>
private static string NormalCalculation(string calcStr)
{
// 掛け算と割り算
string result = MultiDiv(calcStr);

// 足し算と引き算
result = AddSub(result);

return result;
}

/// <summary>
/// * または / の計算を行う
/// </summary>
/// <param name="calcStr"></param>
/// <returns></returns>
private static string MultiDiv(string calcStr)
{
#if CALC_LOG_OUTPUT
return IndividualProcBase(GetHostFuncName(), () =>
#endif
{
// 掛け算、または割り算に一致
string d = DECIMAL;
string pattern = d + @"\*" + d + "|" + d + @"\/" + d;

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
double nResult = 0;

// 演算子で分割し、計算
Match match = Regex.Match(baseMatch.Value, @"\*|\/");

string l = match.Result("$`");
string r = match.Result("$'");

if (match.Value == "*")
{
nResult = (l.ToDouble() * r.ToDouble());
}
else if (match.Value == "/")
{
nResult = (l.ToDouble() / r.ToDouble());
}
else
{
nResult = match.Value.ToDouble();
}

// 小数点第n位まで取得し、後ろの余分な0を切り捨てる
string result = nResult.ToStringWithDigit(SIGNIFICANT_DIGIT);

// 計算結果が + の場合、前方の文字と結合する際に符号なしでくっついてしまう※ので、符号を追加
// ※この関数の結果が10、前方の文字が123だとした場合、123 10 => 12310 になってしまう。
//  + を記述しておけば、 123 +10 => 123+10 と正しい数式の形に戻せる。
string prefix = nResult >= 0 ? "+" : "";

return prefix + result;
});
}
#if CALC_LOG_OUTPUT
);
#endif
}

/// <summary>
/// + または - の計算を行う
/// </summary>
/// <param name="calcStr">文字列の計算式</param>
/// <returns></returns>
private static string AddSub(string calcStr)
{
#if CALC_LOG_OUTPUT
return IndividualProcBase(GetHostFuncName(), () =>
#endif
{
// 足し算、または引き算に一致
string d = DECIMAL;
string pattern = d + @"\+" + d + "|" + d + @"\-" + d;

return CalculationBase(calcStr, pattern, (baseMatch) =>
{
double nResult = 0;

// 演算子で分割し、計算
Match match = Regex.Match(baseMatch.Value, @"(" + d + @")([\+\-])(" + d + @")");
string l = match.Result("$1");
string r = match.Result("$3");
string op = match.Result("$2");

if (op == "+")
{
nResult = (l.ToDouble() + r.ToDouble());
}
else if (op == "-")
{
nResult = (l.ToDouble() - r.ToDouble());
}
else
{
nResult = match.Value.ToDouble();
}

// 小数点第n位まで取得し、後ろの余分な0を切り捨てる
string result = nResult.ToStringWithDigit(SIGNIFICANT_DIGIT);

return result;
});
}
#if CALC_LOG_OUTPUT
);
#endif
}

/// <summary>
/// 各種計算用メソッドのベース
/// </summary>
/// <param name="calcStr">文字列の数式</param>
/// <param name="pattern">正規表現パターン</param>
/// <param name="callback">正規表現にマッチした際に実際に処理を行うコールバック</param>
/// <returns></returns>
private static string CalculationBase(string calcStr, string pattern, Func<Match, string> callback, Match argMatch = null)
{
#if CALC_LOG_OUTPUT
DbgInitProc();
#endif

// 指定したパターンに一致する
Match match = argMatch ?? Regex.Match(calcStr, pattern);
int matchNum = match.Groups.Count - 1;
if (match.Success)
{
// あとで結合するので、計算する文字列の前後を取得しておく
string pre = match.Result("$`");
string suf = match.Result("$'");

#if CALC_LOG_OUTPUT
string mat = match.Result("$&");
// マッチ結果を表示
CalculationLog(" match : " + pre + " [" + mat + "] " + suf + "\n");
for (int i = 1; i <= matchNum; i++)
{
CalculationLog(" ~ $" + (i) + " = " + match.Groups[i] + "\n");
}
#endif

// 計算はコールバックに任せる
string result = callback(match);

// 計算結果を元の場所に結合
result = string.Format("{0}{1}{2}", pre, result, suf);

// まだ計算するべき数式があるかチェック
Match nMatch = Regex.Match(result, pattern);
if (nMatch.Success)
{
#if CALC_LOG_OUTPUT
CalculationLog(" ● >>> more " + _procFuncName + " : " + result + "\n");
#endif
result = CalculationBase(result, pattern, callback, nMatch);

#if CALC_LOG_OUTPUT
DbgEndProc();
#endif
}
else
{
#if CALC_LOG_OUTPUT
DbgEndProc();
CalculationLog(" > end : " + result + "\n");
#endif
}

return result;
}

#if CALC_LOG_OUTPUT
DbgEndProc();
#endif

return calcStr;
}

#if CALC_LOG_OUTPUT
//====================================================================================================
// debug デバッグ出力用
//====================================================================================================

/// <summary>
/// 計算のログを出力する
/// </summary>
/// <param name="log"></param>
private static void CalculationLog(string log)
{
_logText += _indent + log;
}

/// <summary>
/// 呼び出し元のメソッド名を取得
/// </summary>
/// <param name="callerFrameIndex"></param>
/// <returns></returns>
private static string GetHostFuncName(int callerFrameIndex = 1)
{
System.Diagnostics.StackFrame callerFrame = new System.Diagnostics.StackFrame(callerFrameIndex);
System.Reflection.MethodBase callerMethod = callerFrame.GetMethod();
return callerMethod == null ? "" : callerMethod.Name;
}

/// <summary>
/// 計算開始
/// </summary>
static void DbgInitProc()
{
string host_func_name = GetHostFuncName(3);
if (host_func_name != "CalculationBase")
{
CalculationLog(" ■ ---=== " + _procFuncName + " ===---\n");
}
_indent += " |";
}

/// <summary>
/// 計算終了
/// </summary>
static void DbgEndProc()
{
_indent = _indent.Substring(0, _indent.Length - 3);
}

/// <summary>
/// debug デバッグ出力用
/// </summary>
/// <param name="funcName"></param>
/// <param name="callback"></param>
/// <returns></returns>
private static string IndividualProcBase(string funcName, Func<string> callback)
{
string prevProcFuncName = _procFuncName;
_procFuncName = funcName;

string result = callback();

_procFuncName = prevProcFuncName;

return result;
}
#endif
}
}