【テクニカル・上級編】VB.NETで作る独自の簡易数式計算エンジン:DataTable.Computeの限界を突破する安全な構文解析パーサーの自社開発 – Visual Basic (VB / VB.NET)解析バイブル

スポンサーリンク

VB.NETで作る独自の簡易数式計算エンジン:DataTable.Computeの限界を突破する安全な構文解析パーサーの自社開発

レガシーな業務アプリケーションの保守において、避けて通れない要件の一つが「ユーザー定義の計算式評価」である。マスタ画面から動的に数式を入力させ、受発注や原価計算のロジックを動的に変動させたいという要求は、製造・流通・金融の現場では日常茶飯事だ。

多くの開発者は、手っ取り早い解決策として `System.Data.DataTable.Compute` メソッドに飛びつく。

.net
‘ 誰もが一度は通る「危険な」誘惑
Dim dt As New DataTable()
Dim result = dt.Compute(“100 (5 + 3)”, “”)

しかし、チーフアーキテクトの視点から言わせれば、これは実務においては「地雷原の散歩」に等しい。`DataTable.Compute` は内部で.NETの古い式評価エンジンを呼び出しており、予期せぬ型変換例外、パフォーマンスのオーバーヘッド、そして何よりもSQLインジェクションに類する構文インジェクションやセキュリティリスクを孕んでいる。さらに言えば、複雑な丸め処理やカスタム関数(日本の商習慣特有の「切り捨て・四捨五入」など)を挟み込む余地が一切ない。

本稿では、レガシーVB.NET環境(.NET Framework 4.x / .NET 8)を問わず稼働し、メモリ効率と実行速度を極限まで高めた「安全な自社製数式パーサー(AST:抽象構文木ベース)」の設計と実装を詳解する。

—

1. なぜ `DataTable.Compute` では業務に耐えられないのか?

1. パフォーマンスの壁: 文字列を渡すたびに内部でパーサーが走るため、大量行のループ内で使用するとガベージコレクター(GC)への負荷が跳ね上がり、UIスレッドがフリーズする。
2. 仕様の硬直性: 「端数処理(JIS丸めではなく切り捨て)」や「特定マスタの参照」といったビジネスロジックを数式内に埋め込めない。
3. セキュリティと例外処理: 構文エラー時に返される例外メッセージが不親切であり、デバッグが困難。

これを打破するためには、「レキサー(字句解析)」と「パーサー(構文解析)」の二段階からなる軽量なエンジンを自前で持つべきだ。

—

2. アーキテクチャ設計:字句解析から評価木へ

自社製エンジンのパイプラインは以下の3ステップで構成する。

1. Tokenizer (字句解析): 入力文字列(例: `”単価 数量 0.08″`)を、数値、演算子、変数などの「トークン」の配列に分解する。
2. Parser (構文解析): 演算子の優先順位(乗算除算 > 加算減算)を考慮し、再帰下降法(Recursive Descent Parsing)によって「抽象構文木(AST)」を構築する。
3. Evaluator (評価器): 構築されたASTに対し、実行時にバインドされた変数を流し込んで高速に計算を実行する。

特にVB.NETで実装する際は、「オブジェクトのインスタンス化を最小限に抑える構造体の活用」と「Stringの過剰な連結を防ぐスキャン処理」がパフォーマンスの生命線となる。

—

3. 実装コード:ゼロから構築する超高速数式パーサー

以下のコードは、Windows Formsアプリケーション等のビジネスロジック層にそのまま組み込める、完全なセルフホスト型の計算エンジンである。

.net
Imports System
Imports System.Collections.Generic
Imports System.Globalization

Namespace BusinessEngine.Calculator

‘ トークンの種類を定義(メモリ効率を考慮してEnumを最適化)
Public Enum TokenType
Number
OperatorAdd
OperatorSub
OperatorMul
OperatorDiv
LeftParen
RightParen
Variable
End Enum

‘ 構造体によるトークン表現(ガベージコレクションの負荷を軽減)
Public Structure Token
Public ReadOnly Type As TokenType
Public ReadOnly Value As String

Public Sub New(type As TokenType, value As String)
Me.Type = type
Me.Value = value
End Sub
End Structure

‘ 抽象構文木(AST)のベースノード
Public MustInherit Class ASTNode
Public MustOverride Function Evaluate(context As Dictionary(Of String, Decimal)) As Decimal
End Class

‘ 数値・変数ノード
Public Class TerminalNode
Inherits ASTNode
Private ReadOnly _token As Token
Private ReadOnly _isVariable As Boolean

Public Sub New(token As Token)
_token = token
_isVariable = (token.Type = TokenType.Variable)
End Sub

Public Overrides Function Evaluate(context As Dictionary(Of String, Decimal)) As Decimal
If _isVariable Then
If context.ContainsKey(_token.Value) Then
Return context(_token.Value)
End If
Throw New KeyNotFoundException($”計算式内の変数 ‘{_token.Value}’ がコンテキストに定義されていません。”)
Else
Return Convert.ToDecimal(_token.Value, CultureInfo.InvariantCulture)
End If
End Function
End Class

‘ 二項演算ノード
Public Class BinaryOpNode
Inherits ASTNode
Private ReadOnly _left As ASTNode
Private ReadOnly _right As ASTNode
Private ReadOnly _opType As TokenType

Public Sub New(left As ASTNode, opType As TokenType, right As ASTNode)
_left = left
_opType = opType
_right = right
End Sub

Public Overrides Function Evaluate(context As Dictionary(Of String, Decimal)) As Decimal
Dim lVal = _left.Evaluate(context)
Dim rVal = _right.Evaluate(context)

Select Case _opType
Case TokenType.OperatorAdd
Return lVal + rVal
Case TokenType.OperatorSub
Return lVal – rVal
Case TokenType.OperatorMul
Return lVal rVal
Case TokenType.OperatorDiv
If rVal = 0 Then Throw New DivideByZeroException(“計算式内でゼロ除算が発生しました。”)
Return lVal / rVal
Case Else
Throw New NotSupportedException($”未サポートの演算子です: {_opType.ToString()}”)
End Select
End Function
End Class

‘ エンジン本体:字句解析と構文解析を統括
Public Class ExpressionEngine
Private _tokens As List(Of Token)
Private _position As Integer

”’

”’ 数式文字列をパースしてASTを構築する
”’

Public Function Compile(expression As String) As ASTNode
_tokens = Tokenize(expression)
_position = 0
Dim node = ParseExpression()
Return node
End Function

‘ 1. 字句解析 (Lexer)
Private Function Tokenize(expr As String) As List(Of Token)
Dim tokens As New List(Of Token)()
Dim i = 0
Dim len = expr.Length

While i < len Dim c = expr(i) If Char.IsWhiteSpace(c) Then i += 1 Continue While End If If Char.IsDigit(c) OrElse c = "."c Then Dim start = i While i < len AndAlso (Char.IsDigit(expr(i)) OrElse expr(i) = "."c) i += 1 End While tokens.Add(New Token(TokenType.Number, expr.Substring(start, i - start))) Continue While End If If Char.IsLetter(c) OrElse c = "_"c Then Dim start = i While i < len AndAlso (Char.IsLetterOrDigit(expr(i)) OrElse expr(i) = "_"c) i += 1 End While tokens.Add(New Token(TokenType.Variable, expr.Substring(start, i - start))) Continue While End If Select Case c Case "+"c : tokens.Add(New Token(TokenType.OperatorAdd, "+")) Case "-"c : tokens.Add(New Token(TokenType.OperatorSub, "-")) Case ""c : tokens.Add(New Token(TokenType.OperatorMul, "")) Case "/"c : tokens.Add(New Token(TokenType.OperatorDiv, "/")) Case "("c : tokens.Add(New Token(TokenType.LeftParen, "(")) Case ")"c : tokens.Add(New Token(TokenType.RightParen, ")")) Case Else Throw New FormatException($"未知の文字が検出されました: '{c}' (インデックス: {i})") End Select i += 1 End While Return tokens End Function ' 2. 構文解析:加算・減算 (Parser - Level 1) Private Function ParseExpression() As ASTNode Dim node = ParseTerm() While _position < _tokens.Count Dim token = _tokens(_position) If token.Type = TokenType.OperatorAdd OrElse token.Type = TokenType.OperatorSub Then _position += 1 Dim right = ParseTerm() node = New BinaryOpNode(node, token.Type, right) Else Exit While End If End While Return node End Function ' 3. 構文解析:乗算・除算 (Parser - Level 2 - 優先順位高) Private Function ParseTerm() As ASTNode Dim node = ParseFactor() While _position < _tokens.Count Dim token = _tokens(_position) If token.Type = TokenType.OperatorMul OrElse token.Type = TokenType.OperatorDiv Then _position += 1 Dim right = ParseFactor() node = New BinaryOpNode(node, token.Type, right) Else Exit While End If End While Return node End Function ' 4. 構文解析:因子・括弧・変数・数値 (Parser - Level 3) Private Function ParseFactor() As ASTNode If _position >= _tokens.Count Then
Throw New FormatException(“数式が予期せぬ形で終了しました。”)
End If

Dim token = _tokens(_position)
_position += 1

If token.Type = TokenType.Number OrElse token.Type = TokenType.Variable Then
Return New TerminalNode(token)
End If

If token.Type = TokenType.LeftParen Then
Dim node = ParseExpression()
If _position >= _tokens.Count OrElse _tokens(_position).Type <> TokenType.RightParen Then
Throw New FormatException(“閉じ括弧 ‘)’ が見つかりません。”)
End If
_position += 1 ‘ 閉じ括弧をスキップ
Return node
End If

Throw New FormatException($”予期しないトークンです: {token.Value}”)
End Function
End Class
End Namespace

—

4. 現場で活きる実践的使用法とパフォーマンス最適化

このエンジンを Windows Forms アプリケーションやバックグラウンドサービスで運用する際の実装パターンを示す。

.net
‘ — フォームやコントローラーでの呼び出し例 —
Imports BusinessEngine.Calculator

Public Class FormMain
Private Sub ExecuteCalculation()
‘ 1. ユーザーが入力した動的数式
Dim formula As String = “単価 数量 (1 + 税率) – 割引額”

‘ 2. エンジンのインスタンス化とコンパイル(※一度コンパイルしたASTはキャッシュ可能)
Dim engine As New ExpressionEngine()
Dim astRoot As ASTNode = Nothing

Try
astRoot = engine.Compile(formula)
Catch ex As Exception
MessageBox.Show(“数式構文エラー: ” & ex.Message, “エラー”, MessageBoxButtons.OK, MessageBoxIcon.Error)
Return
End Try

‘ 3. 実行時コンテキスト(変数への値バインディング)の構築
Dim context As New Dictionary(Of String, Decimal)(StringComparer.OrdinalIgnoreCase) From {
{“単価”, 1500D},
{“数量”, 12D},
{“税率”, 0.1D},
{“割引額”, 500D}
}

‘ 4. 高速評価の実行
Try
Dim finalResult As Decimal = astRoot.Evaluate(context)
‘ 日本の業務アプリに必須のDecimal型による厳密な計算(浮動小数点誤差を排除)
LabelResult.Text = finalResult.ToString(“N2”)
Catch ex As Exception
MessageBox.Show(“計算実行エラー: ” & ex.Message, “エラー”, MessageBoxButtons.OK, MessageBoxIcon.Error)
End Try
End Sub
End Class

チーフアーキテクトからの実践的アドバイス:

1. ASTのキャッシュ機構: マスタから読み込んだ数式文字列を毎回 `Compile` するのは無駄である。数式文字列をキー、`ASTNode` をバリューとした `Dictionary(Of String, ASTNode)` によるメモリキャッシュ層を一枚噛ませることで、数千行の明細データに対する一括計算であっても、1ミリ秒以下の圧倒的なスループットを実現できる。
2. 型安全性の確保 (`Decimal` の採用): 金融・受発注システムにおいて `Double` や `Single` を使うことはプログラミング規約違反である。本実装では内部演算にすべて `Decimal` を採用しているため、IEEE 754に起因する丸め誤差(例: `0.1 + 0.2 != 0.3`)を完全に排除している。

—

5. 総括

レガシーなシステム環境であっても、「ライブラリがないから」「.NETの標準機能でできないから」という理由で、セキュリティリスクを孕んだ安易な実装(`DataTable.Compute` の乱用や `Eval` 関数の自作など)に逃げるべきではない。

今回解説した自社製パーサーは、わずか150行足らずのコードでありながら、字句解析・構文解析・抽象構文木(AST)というコンパイラ理論の王道を踏襲しており、拡張性も極めて高い。例えば「関数(`ROUND`, `MAX`, `IF` など)」を追加したくなったら、`ParseFactor` の判定部分にキーワード分岐を増やすだけで容易に拡張できる。

自社のビジネスロジックを自らの手で完全にコントロール下に置き、堅牢かつ超高速な基盤を構築することこそが、真のプロフェッショナルエンジニアの仕事である。

タイトルとURLをコピーしました