Rustで化学式のParserを作ってみた

Rustで化学式のParserを作ってみた

2024-2-24

この記事は英語のブログ投稿を日本語に翻訳しています。

Rustを使用した化学式Parserパーサーの作成

TL;DR

この記事では、Rustを使用して化学式のを作成する方法について説明します。Parserの作成にはpestライブラリを使用します。

最終的なコードはGitHubリポジトリにあり、crateはcrates.ioでchemical_formulaとして公開されています。ドキュメントはdoc.rsにあります。

背景

私はX線吸収分光法の分野で働く実験および分析化学者です。また、実験データの分析を支援するソフトウェアツールの開発にも取り組んでいます。

仕事柄、取り扱う化合物の化学組成を扱う必要があります。化学組成は、均一系の分野では通常化学式で表されますが、不均一系の分野(混合物、触媒、ポリマー、工業化学を含む)では重量との混合表現で表されることがあります。

目的

この作業の目的は、化学式のパーサーを作成することです。このパーサーは、ストイキオメトリーと重量%の混合表現、および入れ子の括弧を処理できる必要があります。パーサーは、解析されたデータを標準化学式表現または重量パーセンテージ表現に変換するためのシンプルなAPIも提供する必要があります。

化学式パーサーは多くの言語で一般的に使用されていますが、Rustに焦点を当てると、以下のクレートが利用可能です:

化学式パーサーの比較

Crate深い括弧を解析できるか重量%を解析できるか
chemical_formula (この作業)YesYES
ATOMIOYesNo
acetylene_parserYesNo
chemical_elementsYesNo
chem-parse? Compileできなかったので確認できずNo

これまでのcrateは純粋な化学式を解析できますが、重量%を含む混合表現は解析できませんでした。 chemical_formulaクレートは、これらの両方を解析できる唯一のクレートです。

解析したい化学式の例

  • H2O: 非常にシンプルな式。
  • Fe2(SO4)3: 括弧を含む式。
  • [Cu(H2O)6]Cl2: 深い括弧と角括弧を含む式。
  • Fe2{SO4}3: 括弧の代わりに波括弧を使用する式。
  • 5wt%Pt/SiO2: 重量%と/を含む式。
  • (5wt%Pt/SiO2)50wt%(CeO2)50wt%:重量%を含む混合物表記。

pest Parser

Rustで複雑な文字列入力を解析する際、pestライブラリは強力かつユーザーフレンドリーなツールとして際立っています。Parsing Expression Grammar (PEG)を利用することで、pestは幅広い言語に対して明確かつ簡潔な文法を定義することが可能です。この記事では、pestを使用して化学式を解析する方法について詳しく説明し、そのシンプルさと柔軟性を示します。

PEGとは?

Parsing Expression Grammar (PEG)は、言語の形式文法を記述する方法です。これまでのプログラマー任せなParserと違って、PEGには標準的な文法があり、一度学習すれば他の言語でも応用が可能となります。 PEGでは、選択演算子が最初に一致するルールを選択することを保証し、文法解釈のあいまいさを排除します。

なぜPestを使うのか?

Pestは、PEGの力を活用するRustのParser Generatorです。その主な利点は以下の通りです:

  • 使いやすさ: Pestは文法を定義するための直感的な構文を提供します。
  • パフォーマンス: 文法ルールをRustコードにコンパイルし、高速な解析を実現します。
  • ドキュメント: 初心者から経験豊富なユーザーまでアクセスしやすい包括的なドキュメントを提供します。
  • 柔軟性: シンプルな設定から複雑な言語まで、あらゆるものを解析することができます。

化学式のための文法の定義

化学式自体はparserを書くのはそこまで難しくはありませんが、自前で重量表記も合わせて解析するとなると簡単ではありません。PEGを使うことで、Parserのロジック部分と文法部分を切り離すことが可能です。pestを使用して化学式を解析するための文法は、.pestファイル内で定義されます。ここで、各ルールは式の構造の一部を表します。提供された文法を詳しく見てみましょう:

formula = { SOI ~ expr* ~ EOI }
  • formula: 入力の開始(SOI)から入力の終了(EOI)までをマッチし、ゼロ以上の式を含むルートルールです。
element = { element_symbol ~ stoichiometry }
  • element: 元素記号とその化学量論(stoichiometry)を組み合わせ、元素の量または重量パーセンテージを示します。
group = {
    (("(" ~ (expr)+ ~ ")") | ("[" ~ (expr)+ ~ "]") | ("{" ~ (expr)+ ~ "}")) ~ stoichiometry
}
  • group: 括弧、角括弧、または波括弧で囲れた式のグループを定義し、化学量論に続くオプションです。
expr = _{
    element
  | group
  | separator
}
  • expr: 式を表し、元素、グループ、またはセパレーターのいずれかであることができます。式の構造を再帰的に定義するコアルールです。
element_symbol = {
    "He" | "Li" | "Be" | ... | "Ts" | "Og"
}
  • element_symbol: すべての有効な化学元素記号をリストアップし、認識された元素のみが解析されることを保証します。

文法には、numberweight_percentstoichiometry、およびseparatorのルールも含まれており、式内の数値、重量パーセンテージ、および各種セパレーター(" ", "\t", "."など)を扱います。

pest file全体

formula = { SOI ~ expr* ~ EOI }
element = { element_symbol ~ stoichiometry }
number         = { ("+" | "-")* ~ ASCII_DIGIT* ~ ("." ~ ASCII_DIGIT+)? }
weight_percent = { number ~ "wt%" }
stoichiometry  = { weight_percent | number }
group          = {
    (("(" ~ (expr)+ ~ ")") | ("[" ~ (expr)+ ~ "]") | ("{" ~ (expr)+ ~ "}")) ~ stoichiometry
}
separator = _{ " " | "\t" | "." | "@" | "/" | NEWLINE }
expr      = _{
    element
  | group
  | separator
}
element_symbol = {
    "He"
  | "Li"
  | "Be"
  | "Ne"
  | "Na"
  | "Mg"
  | "Al"
  | "Si"
  | "Cl"
  | "Ar"
  | "Ca"
  | "Sc"
  | "Ti"
  | "Cr"
  | "Mn"
  | "Fe"
  | "Ni"
  | "Co"
  | "Cu"
  | "Zn"
  | "Ga"
  | "Ge"
  | "As"
  | "Se"
  | "Br"
  | "Kr"
  | "Rb"
  | "Sr"
  | "Zr"
  | "Nb"
  | "Mo"
  | "Tc"
  | "Ru"
  | "Rh"
  | "Pd"
  | "Ag"
  | "Cd"
  | "In"
  | "Sn"
  | "Sb"
  | "Te"
  | "Xe"
  | "Cs"
  | "Ba"
  | "La"
  | "Ce"
  | "Pr"
  | "Nd"
  | "Pm"
  | "Sm"
  | "Eu"
  | "Gd"
  | "Tb"
  | "Dy"
  | "Ho"
  | "Er"
  | "Tm"
  | "Yb"
  | "Lu"
  | "Hf"
  | "Ta"
  | "Re"
  | "Os"
  | "Ir"
  | "Pt"
  | "Au"
  | "Hg"
  | "Tl"
  | "Pb"
  | "Bi"
  | "Th"
  | "Pa"
  | "Np"
  | "Pu"
  | "Am"
  | "Cm"
  | "Bk"
  | "Cf"
  | "Es"
  | "Fm"
  | "Md"
  | "No"
  | "Lr"
  | "Rf"
  | "Db"
  | "Sg"
  | "Bh"
  | "Hs"
  | "Mt"
  | "Ds"
  | "Rg"
  | "Cn"
  | "Nh"
  | "Fl"
  | "Mc"
  | "Lv"
  | "Ts"
  | "Og"
  | "H"
  | "B"
  | "C"
  | "N"
  | "O"
  | "F"
  | "P"
  | "S"
  | "K"
  | "V"
  | "Y"
  | "I"
  | "W"
  | "U"
}

化学式の解析にPestを使用する利点

Pestを使用して化学式を解析することには、いくつかの利点があります:

  • 正確さ: PEGの決定的な性質により、式の正確な解析が保証されます。
  • 可読性: 文法は読みやすく理解しやすいため、メンテナンスや更新を容易にします。
  • 効率性: Pestは文法を最適化されたRustコードにコンパイルし、優れたパフォーマンスを提供します。

結論として、pestはRustで化学式を解析するための堅牢なフレームワークを提供します。PEG文法を活用することで、開発者は化学表記の複雑さを容易に扱える柔軟で効率的なパーサーを定義することができます。科学的アプリケーションを構築している場合でも、データ分析ツールを開発している場合でも、または単にRustの可能性を探求している場合でも、pestは解析ニーズに応える機能があると感じました。

ChemicalFormula構造体

化学式はChemicalFormulaという構造体に格納されます。この構造体にはelementstoichiometry、およびwt_percentの三つのフィールドがあります。elementフィールドはElementSymbol列挙型のHashSetになります。stoichiometrywt_percentElementSymbolf64HashMapになります。ElementSymbolは周期表のすべての要素を含む列挙型です。

ChemicalFormulaのメソッド

ChemicalFormula構造体には、以下のメソッドがあります:

  • add_element
    • 式に元素を追加します。
  • add_wt_percent
    • 重量パーセンテージで式に元素を追加します。
  • multiply
    • 化学量論と重量パーセンテージを乗数で乗算します。
  • to_molecular_formula
    • 式を分子式に変換します。
  • to_mol_percent
    • 式をモルパーセントに変換します。
  • molecular_weight
    • 式の分子量を計算します。
  • to_wt
    • 式の分子量表現を計算します。
  • to_wt_percent
    • 式を重量パーセンテージに変換します。
  • multiply_wt_percent
    • 重量パーセンテージを乗数で乗算します。
  • add_formula
    • 別の式を現在の式に追加します。

ChemicalFormula構造体の使用例:

use chemical_formula::prelude::*;
use approx::assert_abs_diff_eq;

let mut formula = ChemicalFormula::new();
let mut formula2 = ChemicalFormula::new();

formula.add_element(ElementSymbol::O, 1.0);
formula.add_wt_percent(ElementSymbol::H, 10.0);
formula.add_wt_percent(ElementSymbol::N, 20.0);
formula2.add_element(ElementSymbol::O, 1.0);
formula2.add_wt_percent(ElementSymbol::H, 10.0);
formula2.add_wt_percent(ElementSymbol::N, 20.0);
formula.add_formula(&formula2);

assert_abs_diff_eq!(formula.stoichiometry[&ElementSymbol::O], 2.0, epsilon = 1e-6);
assert_abs_diff_eq!(formula.wt_percent[&ElementSymbol::H], 20.0, epsilon = 1e-6);
assert_abs_diff_eq!(formula.wt_percent[&ElementSymbol::N], 40.0, epsilon = 1e-6);

化学式の解析

文法と構造体が定義されたら、次のステップはpestライブラリを使用して化学式をAbstract Syntax Tree (AST)に変換します。これは、定義された文法を使用して入力文字列を解析するpestライブラリを使って行われます。

use pest::Parser;
use pest_derive::Parser;
use pest::iterators::Pair;

#[derive(Parser)]
#[grammar = "formula.pest"]
pub struct ChemicalFormulaParser {}

再帰的な解析

Abstract Syntax Tree (AST)は、再帰的な関数呼び出しとmatch文によって再帰的に解析されます。match文は、文法で定義された異なるルールを一致させるために使用されます。

fn parse_formula_pairs(pair: Pair<Rule>) -> ChemicalFormula {
    match pair.as_rule() {
        Rule::formula => pair
            .into_inner()
            .map(|x| parse_formula_pairs(x))
            .fold(ChemicalFormula::new(), |acc, x| acc.add_formula(&x)),
        Rule::group => {
            let mut formula = ChemicalFormula::new();
            for p in pair.into_inner() {
                match p.as_rule() {
                    Rule::element => {
                        formula.add_formula(&parse_formula_pairs(p));
                    }
                    Rule::stoichiometry => {
                        let stoichiometry = p.as_str().parse::<f64>().unwrap();
                        formula.multiply(stoichiometry);
                    }
                    _ => unreachable!(),
                }
            }
            formula
        }
        Rule::element => {
            let element_symbol = pair.as_str();
            let stoichiometry = pair.as_str().parse::<f64>().unwrap_or(1.0);
            ChemicalFormula::new().add_element(ElementSymbol::from_str(element_symbol), stoichiometry)
        }
        _ => unreachable!(),
    }
}

抽象構文木は、再帰関数の呼び出しとmatch文によって再帰的に解析されます。match文は、文法で定義された異なるルールにマッチするために使用されます。

fn parse_formula_pairs(pair: Pair<Rule>) -> ChemicalFormula {
    match pair.as_rule() {
        Rule::formula => pair
            .into_inner()
            .map(|x| parse_formula_pairs(x))
            .fold(ChemicalFormula::new(), |acc, x| acc.add_formula(&x)),
        Rule::group => {
            let mut formula = ChemicalFormula::new();
            for p in pair.into_inner() {
                match p.as_rule() {
                    Rule::element => {
                        formula.add_formula(&parse_formula_pairs(p));
                    }
                    Rule::stoichiometry => {
                        let stoichiometry = p.into_inner().next().unwrap();

                        match stoichiometry.as_rule() {
                            Rule::number => {
                                formula.multiply(stoichiometry.as_str().parse().unwrap());
                            }
                            Rule::weight_percent => {
                                formula.multiply_wt_percent(stoichiometry.as_str().parse().unwrap()).unwrap();
                            }
                            _ => unreachable!(),
                        }
                    }
                    _ => unreachable!(),
                }
            }
            formula
        }
        Rule::element => {
            let element = pair.into_inner().next().unwrap().as_str();
            let stoichiometry = pair.into_inner().next().unwrap().as_str().parse().unwrap();
            ChemicalFormula::new().add_element(ElementSymbol::from_str(element), stoichiometry)
        }
        _ => ChemicalFormula::new(),
    }
}

ここでRuleの名前は、PEGで定義されたルールの名前と同じです。pair.into_inner()を使用して現在のペアの内部ペアを取得し、match 文を使用してそれぞれのペアを解析します。

match文は、現在のペアのRuleを分析しますが、match文の解析力をフル活用してPEGで定義されたルールを見逃さないようにしてれます。そして、現在のペアのRuleに基づいて、ChemicalFormula構造体の適切なメソッドが呼び出されます。

Rustのmatchサイコー!!

Crateの公開

今回始めてcrates.ioに公開した最初のCrateでした。終わってみての感想としては、Crateを公開するプロセスは非常にシンプルでした。Crateを公開する手順は以下の通りです:

  • GitHubアカウントを使用してcrates.ioにログインし、APIトークンを取得します。
  • cargo loginでcrates.ioにログインします。
  • 必要なフィールドを含むCargo.tomlファイルを作成します。
  • cargo publish --dry-runを実行してエラーがないかチェックします。
  • cargo publishを実行してクレートを公開します。

他の言語でもこのようにシンプルにソフトウェアを公開できたらいいのにと思います。非常に満足できる簡単なプロセスで、このシンプルさを実現してくれたRustコミュニティに感謝したいと思います。

結論

入れ子の括弧や重量パーセンテージを含む化学式を解析できる化学式パーサーについて議論しました。また、解析されたデータを格納するために使用されるpestライブラリとChemicalFormula構造体についても議論しました。このクレートはcrates.ioでchemical_formulaとして公開され、完全なドキュメントと参照はdoc.rsで利用可能です。

他の言語での作業と比較して、developer experience (DX)が非常に良かったです。

このCrateの初期バージョンの作成にはわずか2日しかかかりませんでした。そのスムーズさは、Rust言語およびそのエコシステムの設計とコミュニティのサポートによるものです。特に、pestライブラリの使いやすさと強力な機能は、複雑なパースタスクを容易にし、開発時間を大幅に短縮しました。