Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
0
Help us understand the problem. What are the problem?

コマンドラインオプションをJSON-Fortranで扱いやすくする

背景

Fortranでコマンドラインオプションを取得する場合,基本的には標準手続を用いて文字列で参照することになります1.取得自体はそれなりに簡単にできますが,その後の処理,例えばオプションをオプションのキーと値に分解する作業,必要なオプションのチェック,文字列を整数や論理値に変換する作業は,それなりに大変です.
これまでは,select caseなどを用いてなんとか扱っていた,あるいはキーワードを設けずに順序を決めてオプションを取り扱ってきた方が多いのではないかと思います.

一方で,Fortranでjson形式のファイル入出力を実現するライブラリに,JSON-Fortranがあります.JSON-Fortranの使い方を調べていると,JSONの書式に沿った文字列を解釈してくれることが判りました.この機能をうまく利用することで,コマンドラインオプションが簡単に取り扱えるようになるのでは?と思い立ちました.

環境

  • Windows 10
  • gfortran 10.3.0
  • cmake 3.20.3
  • GNU Make 3.8.1
  • JSON-Fortran 8.3.2
  • fortran-stdlib (commit hash 590adbea87af630c504a113c8ce5e579ef13653f)

必要なライブラリのインストール

JSON-Fortranのインストール

gitからリポジトリをクローンし,CMakeを利用してビルドします.

> git clone https://github.com/jacobwilliams/json-fortran.git
> cd json-fortran
> cmake -B build -G "Unix Makefiles" -DCMAKE_Fortran_COMPILER=gfortran -DSKIP_DOC_GEN=TRUE
> cmake --build build

ここで,-DSKIP_DOC_GEN=TRUEは,FORDによるドキュメント生成を回避するオプションです.JSON-Fortranは,CMakeによるCONFIGUREのタイミングで,FORDによるドキュメント生成を行います.ドキュメントは必要ですが,これには結構時間がかかるので,今は生成しないようにします.

インストールをCMakeのコマンドでやってしまうと,パーミッションエラーが出る場合があるので,カレントディレクトリに一度インストールして,必要なモジュールファイルとライブラリをコピーすることにします.

> cmake --install build --prefix .

jsonfortran-gnu-8.2.3というディレクトリが作られ,その下にあるlibディレクトリにモジュールファイルとライブラリがコピーされています.必要に応じてコピーして利用することにします.

Fortran stdlibのインストール

文字列操作の一部に,fortran-langコミュニティによって開発されているstdlibを利用します.stdlibも,gitからリポジトリをクローンし,CMakeを利用してビルドします.

> git clone https://github.com/fortran-lang/stdlib.git
> cd stdlib
> cmake -B build -G "Unix Makefiles" -DCMAKE_Fortran_COMPILER=gfortran -DCMAKE_MAXIMUM_RANK:String=7
> cmake --build build

-DCMAKE_MAXIMUM_RANK:String=7は,stdlibで処理できる配列の最大RANK2を指定します.これを指定しない場合,コンパイラがサポートしているFortran標準に応じて,4,7,15のいずれかが与えられます.最近のコンパイラでは15が使われますが,コンパイル時間が長くなるので,意図的に最大RANKを下げています.

インストールも,CMAKEを用いてやってしまいます.

> cmake --install build --prefix path/to/install_dir

path/to/install_dirは各自の所望のディレクトリパスに置き換えてください.stdlibでは,インストール先のディレクトリにサブディレクトリincludeとlibを作成し,モジュールファイルをincludeに,ライブラリをlibにコピーします.しかし,利用したハッシュのソースはWindowsでテストされていないのか,includeディレクトリの設定が上手くいっておらず,ディレクトリ名がOFFとなってしまいます.

そのため,一度インストールをした後,OFFディレクトリの名前を,includeに変更します.

JSON-Fortranのモジュールファイルとライブラリのコピー

stdlibをインストールしたincludeとlibに,JSON-Fortranのモジュールファイルとライブラリをコピーします.

最終的に,下記のようなディレクトリ構造で作業することを想定しています.

├── include
│   ├── JSON-Fortranの.modファイル
:   :
│   ├── Fortran-stdlibの.modファイル
:   :
│   
├── lib
│   ├── libjsonfortran.a
│   └── libfortran_stdlib.a
└── main.f90

コマンドラインオプションの取得とJSON形式への変換

JSON-Fortranでは,json_file型の変数を作成し,型束縛手続きloadでjsonファイルを読み込みます.それ以外に,deserializeという型束縛手続きで,文字列として与えられたJSONを解釈してくれます.

そこで,戦略として下記の手順でコマンドラインオプションをJSON形式に変換することを検討します.

  1. Fortranの標準手続を利用して,実行コマンドを含むコマンドラインオプションを取得する
  2. 文字列から実行コマンドを削除する
  3. --等を目印に,オプションを分割する
  4. オプションのキーワードと値を分離する
  5. JSONの形式"key":"value",として文字列に追加する

コマンドラインオプションの取得

Fortranには,コマンドラインオプションを取得する手続が用意されています.

  • コマンド全体を取得するget_command()
  • 実行コマンドを含むオプションの個数を取得するcommand_argument_count()
  • 各オプションを取得するget_command_argument()

今回は,オプションを個別に分解せずに全てを取得するので,get_command()を利用します.オプションの長さは事前にはわからないので,一度その長さを取得して文字列を割り付けた後,文字列として取得するのが定石です.

    character(:), allocatable :: command_string 
    integer(int32) :: len_string

    call get_command(length=len_string) ! コマンドの長さを取得し,文字列割付に利用

    allocate (character(len_string) :: command_string)
    call get_command(command=command_string)

aという実行ファイル(gfortranの標準の実行ファイル名)を--help --config config.json --version --some_number 10というオプション付きで実行すると,command_stringの内容はa --help --config config.json --version --some_number 10となります.

コマンドラインオプションを扱う上で面倒なのは,--help--versionのようにキーワードだけで完結するコマンドと,--config config.jsonのようにキーワードと値をもつオプションがあることです.

実行コマンドの削除

次に,実行コマンドを文字列command_stringから削除します.

その際,コマンドの接頭記号--,に置き換えることで,後々の処理をやりやすくしておきます.

    use :: stdlib_strings, only:replace_all
    character(:), allocatable :: option_string

    integer(int32) :: option_index

    ! オプションの--を,に置き換えて区切りの目印にする
    option_string = replace_all(command_string, " --", ",")

    ! 一つ目の,までは実行コマンドなので,オプションだけを取り出す
    option_index = index(option_string, ",") + 1 ! 一つ目のオプションのindex(=一つ目の,より1文字後ろ)
    option_string = option_string(option_index:) ! オプションだけを取り出す

オプションの接頭辞--を,その前の空白も含めて,に置き換える際に,stdlib_stringsで定義されているreplace_allを利用します.置き換えると,文字列command_stringの内容はa,help,config config.json,version,some_number 10となります.ここでは,オプションの間に2個以上の空白がある場合を想定していません.

このように置き換えると,一つ目の以降がオプションであると明確になるので,一つ目の,以降をオプションとして取り出します.つまり,文字列option_stringの内容はhelp,config config.json,version,some_number 10です.

JSON形式への変換

オプションだけを取り出せて,かつ,で区切られているので,簡単に分解できそうです.このうち,config config.jsonのように,キーワードと値があるオプションについては,を目印に分離します.helpのように,値を持たないオプションについては,値として論理値の真を採用します.
末尾のオプションとそれ以外で分岐があるので,もう少しうまく記述できそうですが,ここを工夫することが主目的ではないのでこの程度にしておきます.もっと効率のよい方法をご存じの方は教えてください.

キーワードと値を分離できたら,あとはjson形式({"key":"val"})として文字列json_stringに追加していきます.

    character(*), parameter :: keyword_separator = " "
    character(:), allocatable :: json_string

    character(:), allocatable :: options, an_option
    character(:), allocatable :: key, val
    integer(int32) :: num_options, n, index_separator

    num_options = count(option_string, ",") + 1 ! ,の数からオプションの数を定める

    options = option_string ! 作業用配列にコピー

    json_string = '{'//LF
    do n = 1, num_options
        if (n < num_options) then
            an_option = options(1:index(options, ",")-1) !& 先頭から,の一文字前までがオプション
        else
            an_option = options(:) ! 最後のオプションは,による区切りがない
        end if

        index_separator = index(an_option, keyword_separator)
        if (index_separator > 0) then
            ! keyword separator前後で,オプションをkeyとvalueに分ける
            key = an_option(1:index_separator-1) !&
            val = an_option(index_separator+len(keyword_separator):) !&
        else
            ! --helpや--versionといったvalueを持たないオプションは,valueを論理値とする
            key = an_option
            val = "true" ! JSON-Fortranで論理値の真
        end if

        json_string = json_string//'"'//key//'" : "'//val//'",'//LF

        if (n < num_options) options = options(index(options, ",")+1:) !& 解釈済みのオプションを破棄
    end do
    json_string = json_string//'}'//LF

改行の表現には,stdlib_asciiで定義されている定数LF(line feed)を用いています.

json_stringの内容は下記のようになります.
{
"help" : "true",
"config" : "config.json",
"version" : "true",
"some_number" : "10",
}

JSON-Fortranのjson_file形式への変換

JSONの書式に沿った文字列が作成できたら,後は簡単です.json_file型変数を宣言・初期化して,型束縛手続きdeserializeに渡すだけです.

    use :: json_module

    type(json_file) :: arg

    call arg%initialize()
    call arg%deserialize(json_str) ! json書式の文字列を解釈する

引数参照が終わった後は,型束縛手続きdestroyを呼び出して終了処理を行います.

    call arg%destroy()

JSON-Fortranは,json_fileで値を処理する際,内部的にポインタを使用します.そのため,終了処理は行うようにした方が安心です.

オプションの参照

オプションをjson_file型変数に変換した後にオプションを参照するには,型束縛手続きgetを利用します.

    integer(int32) :: num

    call arg%get("some_number", num)

オプションsome_numberの値10numに代入されます.文字列として数字を与えても,自動で変換されます.

getにはoptionalな引数foundがあり,引数が見つかった場合にfoundに指定した変数に.true.が返ります.これを利用すると,helpversionのように値を持たず,かつ指定されたときにプログラムの挙動が変化するオプションをうまく扱うことができます.

    logical :: flag, found

    call arg%get("help", flag, found=found) ! オプション"help"の存在をチェック. オプションの値は意味を持たないが,省略はできない.
    if (found) then
        print '(A)', "usage: ..." ! "help"が指定された場合は,usageを表示する
    end if

オプションが指定されていればその値を用い,オプションがない場合に既定の値を用いたい場合には,get手続のoptionalな引数defaultを利用します.

    character(:),allocatable :: config_file
    logical :: found

    call arg%get("config", config_file, found, default="config.json")
    if (.not. found) then
        print '(A)', "config file is not specified. default config file '"//config_file//"' is used."
    end if
    print '(A)', "reading "//config_file

オプションとして--config filenameが与えられていれば,その値filenameconfig_fileの値として用いられます.一方で,オプションを付けない場合は,config_fileには"config.json"が代入されます.getで文字列を取得する場合,文字列を事前に割り付けておく必要はありません.

プログラム全景とコンパイルオプション

動作確認のために,下記のようなプログラムを作成し,実行しました.

プログラム全景
main.f90
program main
    use, intrinsic :: iso_fortran_env
    use :: json_module
    implicit none

    character(*), parameter :: keyword_separator = " "

    type(json_file) :: arg

    call create_argument_json(arg)

    block
        logical :: found, flag
        character(:), allocatable :: config_file
        integer(int32) :: num

        call arg%get("help", flag, found=found)
        if (found) then
            print '(A)', "usage: ..."
        end if

        call arg%get("version", flag, found=found)
        if (found) then
            print '(A)', "version=0.0.1"
        end if

        call arg%get("config", config_file, found, default="config.json")
        if (.not. found) then
            print '(A)', "config file is not specified. default config file '"//config_file//"' is used."
        end if
        print '(A)', "reading "//config_file

        call arg%get("some_number", num)
        print *, num
    end block

    call destroy_artument_json(arg)

contains

    !| 実行コマンドを除くオプションを,json-fortranのjson_file形式で取得する.
    subroutine create_argument_json(argument_json)
        implicit none
        type(json_file), intent(inout) :: argument_json
            !! 取得されたオプション

        character(:), allocatable :: cmd_str, opt_str, json_str

        call get_command_string(cmd_str) ! オプションを含む実行コマンドを文字列で取得する

        opt_str = get_option_string(cmd_str) ! オプションだけを取り出す

        json_str = convert_to_json(opt_str) ! オプションをjson書式に置き換える

        call argument_json%initialize()
        call argument_json%deserialize(json_str) ! json書式の文字列を解釈する
    end subroutine create_argument_json

    !| json_file形式で取得されたオプションを破棄する.
    subroutine destroy_artument_json(argument_json)
        implicit none
        type(json_file), intent(inout) :: argument_json
            !! 取得されたオプション

        call argument_json%destroy()
    end subroutine destroy_artument_json

    !| 実行コマンドを文字列で取得する
    subroutine get_command_string(command_string)
        implicit none
        character(:), allocatable, intent(inout) :: command_string
        integer(int32) :: len_string

        call get_command(length=len_string) ! コマンドの長さを取得し,文字列割付に利用

        allocate (character(len_string) :: command_string)
        call get_command(command=command_string)
    end subroutine get_command_string

    !| 実行コマンドの文字列からオプションだけを取り出す.
    function get_option_string(command_string) result(option_string)
        use :: stdlib_strings, only:replace_all
        implicit none
        character(*), intent(in) :: command_string
        character(:), allocatable :: option_string

        integer(int32) :: option_index

        ! オプションの--を,に置き換えて区切りの目印にする
        option_string = replace_all(command_string, " --", ",")

        ! 一つ目の,までは実行コマンドなので,オプションだけを取り出す
        option_index = index(option_string, ",") + 1 ! 一つ目のオプションのindex(=一つ目の,より1文字後ろ)
        option_string = option_string(option_index:) ! オプションだけを取り出す
    end function get_option_string

    !| オプションの文字列をjson書式に変換する.
    function convert_to_json(option_string) result(json_string)
        use :: stdlib_strings, only:count
        use :: stdlib_ascii, only:LF
        implicit none
        character(*), intent(in) :: option_string
        character(:), allocatable :: json_string

        character(:), allocatable :: options, an_option
        character(:), allocatable :: key, val
        integer(int32) :: num_options, n, index_separator

        num_options = count(option_string, ",") + 1 ! ,の数からオプションの数を定める

        options = option_string ! 作業用配列にコピー

        json_string = '{'//LF
        do n = 1, num_options
            if (n < num_options) then
                an_option = options(1:index(options, ",")-1) !& 先頭から,の一文字前までがオプション
            else
                an_option = options(:) ! 最後のオプションは,による区切りがない
            end if

            index_separator = index(an_option, keyword_separator)
            print *, index_separator
            if (index_separator > 0) then
                ! keyword separator前後で,オプションをkeyとvalueに分ける
                key = an_option(1:index_separator-1) !&
                val = an_option(index_separator+len(keyword_separator):) !&
            else
                ! --helpや--versionといったvalueを持たないオプションは,valueを論理値とする
                key = an_option
                val = "true" ! json-fortranで論理値の真
            end if

            json_string = json_string//'"'//key//'" : "'//val//'",'//LF

            if (n < num_options) options = options(index(options, ",")+1:) !& 解釈済みのオプションを破棄
        end do
        json_string = json_string//'}'//LF
    end function convert_to_json

end program main

コンパイルおよび実行コマンドは,それぞれ下記を用いました.

コンパイル
> gfortran main.f90 -std=f2018 -Iinclude -Llib -lfortran_stdlib -ljsonfortran
実行コマンド
> a --help --version --some_number 10
usage: ...
version=0.0.1
config file is not specified. default config file 'config.json' is used.
reading config.json
          10

--help--versionが指定されたとき,オプションを指定しなかったときの挙動が想定通りになっており,オプションを正しく参照できていると判断できます.

まとめ

JSON-Fortranを用いて,コマンドラインオプションを取り扱う方法を紹介しました.オプションの文字列からJSONの形式への変換に少々作業がありますが,一度変換できると大幅に柔軟性が向上します.JSON-Fortran本来の使い方ではありませんが,試してみる価値はあると思います.

また,処理の簡略化のために,Fortranのstdlibも利用しました.改行を表す文字コードをいちいち定義する必要がなく,またreplace_allのように標準でサポートされていない処理も実行できます.まだまだ開発途上なので,もっと充実してくれることを期待します.


  1. Fortranの標準手続きを用いてコマンドライン引数を取得する 

  2. 配列の次元といった方がしっくりくるかも知れませんが,用語としてはRANKの方が正しいはずです. 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
0
Help us understand the problem. What are the problem?