44
39

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

FortranAdvent Calendar 2019

Day 24

FORTRANのModernizeに使えるいくつかの機能

Last updated at Posted at 2020-01-07

概要

FORTRANコードを書き直す際に役立つFortranの機能を紹介します.本記事において,Modernizeとは,その当時よいとされていた(あるいはそのようにしか組めなかった)コードを現代のスタイルに置き換える行為を意味しています.

FORTRANコードとソフトウェア開発

FORTRANで書かれたプログラム(以下,FORTRANコード)は, 望ましくないコードの例としてやり玉に挙げられます.確かに,現代のプログラミングのスタイルからすると,望ましくない書かれ方をしていることが多々あります.

しかし,望ましくないFORTRANコードが生まれた時点で既に望ましくなかったかというとそうではなく,当時の環境ではそのように書くのが普通でしたし,そのようにしか書けない制約がありました.
それらが望ましくないと言われるようになるまでに,計算機環境,プログラミングパラダイムなど,様々な変化がありました.Fortranもそれらのパラダイムに対応するように規格を更新しています.

FORTRANが嫌いなあまり,またその制約から逃れる道を探して異なるプログラミング言語を使い始め,滅ぼして全てを置き換えよと主張したくなる気持ちもわかります.しかし,問題の原因は言語にはありません.FORTRANで課されていた(現代から見た)制約は既に撤廃されています.問題は,望ましくないコードの更新を妨げる文化にあります.

FORTRANは,C言語と比較して配列の演算を容易に記述することができ,またポインタなど理解が困難な機能を取り扱う必要がありません.そのため,プログラミングを専門としない技術者,研究者の間で愛用されてきました.

企業では,効率的に働くことが求められます.プログラムの機能強化で同じ処理が出てきたとき,まずは関数に分離することを検討するでしょうが,そうすると関数の仕様を決めて仕様書および詳細設計書を書き,テストを行う必要が生じます.そうすると,当たり前ですが,工数が増えますし,バグを埋め込む可能性も0ではありません.既に動いているコードに手を入れ,バグを埋め込んでしまうと,上役や元請けに激詰めされる上に,なぜなぜ分析をして再発防止策を提出し,チェックリストを作るなど余計な仕事が増えます.このような状況では,関数に分離するよりは,コピペをしてしまう方が,その案件を安くこなす上で効率的なのです.

企業・大学にかかわらず,本務以外にプログラミングやその周辺知識・技術を勉強しても,本務で成果を上げなければ一切評価されません.また,研究開発において,誰も実現していない計算手法を開発・実装する際に,適した開発方法論は無いように思われます.これは個人の能力ではなく,数値計算という分野全体に関係しています.特に,いわゆるスキーム屋さんは肌感覚で理解しているでしょう.解決したい問題は分かっていても,解決に至る道筋はおぼろげに見えるだけです.提案した新規アルゴリズムの有用性を,様々な条件のテストを通して明らかにしていくわけですが,実装して初めて判明する問題もあるでしょう.これらは,ソフトウェア開発における要求定義以前の段階です.

だからといって,ソフトウェア工学に関する知識が不要だとは考えていません.むしろ,ソフトウェア工学やソフトウェア開発手法を深く理解し,数値計算に適した形で取り込まなければならない(ならなかった)でしょうし,近視眼的で間違った効率化を追求したソフトウェア開発も改めるべきでしょう.数値計算,特にFORTRANの適用先であるスーパーコンピュータでの大規模計算において,速さが全てで1 msでも遅くなるような機能は不要だと豪語する方もいるでしょうが,ソフトウェアにおいて最も重要なのは,正しく動くことです.そして,プログラムが正しく作られていることを確認し,実装の際に間違いを減らすために,(見た目の美醜ではなく理解しやすいという意味で)美しく書くのです.

硬直化した文化を変化させるのは,容易ではありません.できることからコツコツと進めて行くしかないのですが,その一つがコードのModernizeです.Modernizeはリファクタリングと同じですが,古いパラダイムのコードを現代のパラダイムで置き換えるという行為を,著者が勝手にそう呼んでいます.いくら新しい言語が優秀だからといって,20年間開発され続け,強固に密結合された,設計書もまばらでテストも存在しないコードをいきなり置き換えるには,かなりの時間がかかります.人が動くとなると,その分お金も発生します.一切儲けが出ないリファクタリングを快く許可してくるれることなど,まずありません.その言語を使えるのがあなた一人だけなら,業務の継続という観点ではリスクでしかありません.また,誤った文化を継承した状態で言語だけを変えても,上述の文化が持ち込まれるだけです.せっかく苦労して導入したお気に入りの言語が汚染されていく様子を,悲しげに見つめることになるでしょう.新しい言語が好きな方には,FORTRANにとらわれず,数値計算に適した開発スタイルの開拓に力を使ってくれることを希望します.

著者が考える解決策の一つが,FORTRANコードのFortranによる部分的な置き換えです.Fortranは強力な後方互換性を有していますし,廃止事項もコンパイラベンダが独自にサポートを継続しています.諸手を挙げて歓迎できない状況でもありますが,そのおかげでFORTRANとFortranを混ぜたとしても,コンパイルはほぼ確実に可能です.

FORTRANコードあるある,その要因と解決法

FORTRANコードあるある

FORTRANコードあるあると,それらの置き換え方法を探っていきます.

  1. 重要な変数全てがグローバル
  2. 各地にコピペされた同じコード
  3. 異様に多いソースファイル群
  4. 短く,意図の読み取れない変数名
  5. 自在に飛び回る行番号

重要な変数全てがグローバル

計算に必要なパラメータや物理変数を格納する配列をあるモジュールに集約し,procedure(subroutineとfunction)内でuseして利用するスタイルです.例を以下に示します.これは,commonや静的なメモリプールの使用を止め,脱FORTRANを目指したコードでよくみられます.

main.f90
program main
    implicit none

    call umag
end program main
param.f90
module param
    implicit none
    integer,parameter :: N = 100
    real*4 u(3,N)
    real*4 u2(3,N)

    integer*1,parameter :: K_ON = 1
    integer*1,parameter :: K_OFF = 0
    integer*1 :: JQWIOD = K_ON
end module param
umag.f90
subroutine UMAG
    use param
    implicit none

    integer :: i
    real*4 :: mag

    if(JQWIOD == K_ON)then
        do i = 1, N
            mag = sqrt(U(1,i)**2 + U(2,i)**2 + U(3,i)**2)
            ! : magを何かに使う
        end do
    else
        do i = 1, N
            mag = sqrt(U2(1,i)**2 + U2(2,i)**2 + U2(3,i)**2)
            ! : magを何かに使う
        end do
    end if
end

プログラムを書く側からすると,いつでもどこでも全ての変数にアクセス可能であり,かつprocedureの引数を書かなくて済むので非常に手軽に感じます.一方で,これは変数を全てグローバルにしているのと代わらず,変数がどのprocedureで読み書きされているか不明瞭になります.また,全てのprocedureが密結合になるので,procedureを他のプログラムで再利用することは,ほぼ不可能でしょう.

改善は,2段階で行います.

  1. useonly句を付け,利用する変数を可視化
  2. その後,procedureに引数を設け,密結合を緩和
main.f90
program main
    use param,only: U, U2 ! only句を付けて使う変数を可視化
    implicit none

    call umag(U, U2) ! 引数を設ける
end program main
umag.f90
subroutine UMAG(U, U2)
    use param, only: N, JQWIOD, K_ON ! only句を付けて使う変数を可視化
    implicit none
    real*4,intent(in) :: U(3,N),U2(3,N) ! 引数にintent属性を付け,用途を明示
    integer :: i
    real*4 :: mag

    if(JQWIOD == K_ON)then
        do i = 1, N
            mag = sqrt(U(1,i)**2 + U(2,i)**2 + U(3,i)**2)
            ! : magを何かに使う
        end do
    else
        do i = 1, N
            mag = sqrt(U2(1,i)**2 + U2(2,i)**2 + U2(3,i)**2)
            ! : magを何かに使う
        end do
    end if
end

subroutine UMAG内で,引数は形状明示配列U(3,N),U2(3,N)として受けていますが,引数を形状引継ぎ配列U(:,:),U2(:,:)として受ければ,Nも排除できます.必要になれば,size関数を用いて,size(配列,次元)として取得すればよいでしょう.

何らかの判別を行うパラメータJQWIODlogical型に変更すると,K_ONK_OFFといった定数の定義も排除できます.これらは論理型がなかった時代によく使われていたスタイルで,接頭辞のK_はドイツ語のkonstanteに由来するようです.

こうすると,引数が異常に多くなるprocedureが現れます.その際は,同じ物理量(UU2のように,何らかの計算条件によって参照される変数が違う)全てを渡すことを止めることと,構造体の導入を検討してください.大体はそれで解決できます.

各地にコピペされた同じコード

上のumag.f90でおや?と思ったことでしょう.一つのprocedure内や異なるproceduer内で,共通する処理が複数箇所に出現します.

    if(JQWIOD == K_ON)then
        do i = 1, N
            mag = sqrt(U(1,i)**2 + U(2,i)**2 + U(3,i)**2)
            ! : magを何かに使う
        end do
    else
        do i = 1, N
            mag = sqrt(U2(1,i)**2 + U2(2,i)**2 + U2(3,i)**2)
            ! : magを何かに使う
        end do
    end if

これは上でも書きましたが,動いている実績のあるコードを使うので,コピペは安全・簡潔で効率的と見なされます.短期的な成果を追い求める余り,(誤解されている方の)DRYの原則が,Don't Repeat YourselfからDuplicated Repeatedly by Yourselfに変質したのでしょう.しかし,この記事を読んでいる全員が理解しているように,コピペをすると同じ処理があちこちに分散し,ある箇所の処理を変更した際に他の箇所の更新忘れが発生します.変更を忘れてもバグを埋め込んでも正しく動かないことは同じですが,変更忘れの方は考慮漏れとよばれる軽微な罪として扱われます.壊したわけではないですし,元請けはコードの詳細を知りませんので,精々使いもしないチェックシートを作る程度で終わります.

これを改善するには,基本に立ち返って共通部分を関数として切り出します.その際,同一手続き内に同じコードが存在する場合には,内部副プログラムが利用できます.内部副プログラムというよりは,クロージャーと呼んだ方が理解しやすい方もいるでしょうか.procedureの中にprocedureを設けることができる機能です.

umag.f90
subroutine UMAG(U, U2)
    use param, only: N, JQWIOD, K_ON
    implicit none
    real*4,intent(in) :: U(3,N),U2(3,N)
    real*4 :: mag

    if(JQWIOD == K_ON)then
        do i = 1, N
            mag = computeVelocityMagnitute(U)  ! 内部副プログラム呼出に変更
            ! : magを何かに使う
        end do
    else
        do i = 1, N
            mag = computeVelocityMagnitute(U2) ! 内部副プログラム呼出に変更
            ! : magを何かに使う
        end do
    end if

    contains
    ! 速度の絶対値を計算する内部副プログラム
    function computeVelocityMagnitute(v) result(magnitude)
        implicit none
        real*4,intent(in) :: v(3,N)
        real*4 :: magnitude
        integer :: i

        magnitude = sqrt(v(1,i)**2 + v(2,i)**2 + v(3,i)**2)
    end function
end

内部副プログラムを使うと,ひとまず変更範囲を一つのprocedure内に抑えることができます.これで動作を確認した後,独立したprocedureとして切り出すのです.内部副プログラムでは親スコープの変数も参照可能ですし,クロージャーもそのように使うのでしょうが,後々個別の手続として切り出す可能性が高い場合は,内部副プログラム内で使う親スコープの変数も,引数に入れた方がよいと個人的には考えています.

似たようなことを実現できる機能として文関数もありますが,文関数は既に廃止事項となっているので,使わないようにしてください.洗練された形で復活してくれることを望んではいますが.

異様に多いソースファイル群

ある一定の規模を超えると,FORTRANプログラムはソースファイルが異様に多くなります.これはかつてグッドプラクティスとされていた,1ファイルに1 procedureだけを書くというスタイルの名残です.

main.f90
program main
    implicit none

    integer,external :: doublify ! doublifyの定義の参照を阻害

    call hello()
    print *,doublify(2) !VSCodeで実装の確認のために「定義へ移動」しようとしても,external宣言までしか移動できない

end program main
hello.f90
subroutine hello()
    implicit none

    print *,"hello"
end subroutine hello
doublify.f90
function doublify(i) result(i2)
    implicit none
    integer :: i
    integer :: i2

    i2 = i*2
end function doublify

利点としては,あるprocedureの内容を差し替えるのが楽になることと,差し替えたprocedureだけが再コンパイルされるので,コンパイル・リンク時間が短くて済むということが挙げられています.一方で,全てのprocedureが外部procedureになるので,functionを利用するにはexternal宣言が必要になること,procedure同士の関係が不明瞭になること,ファイルの整理が困難になることが欠点です.

実際は,内容を差し替える際に上書きされることはなく,ファイルのバックアップが作られます.また,先述の例のように,変数UU2があるように,かなり行き当たりばったりで変数やprocedureが増えるので,UMAG2UMAG0などが作られ,それら全てが一つのフォルダにが置かれるなど,かえって取扱いが困難になっているのが実情ですし,それを防止することはできません.1ファイル1 procedureは明らかにアンチパターンです.

エディタに依存する話ですが,VSCodeで関数doublifyの定義へ移動しようとしても,external宣言(integer,external :: doublify)までしか移動できず,実装に辿り着けないので,そういった意味でも好ましくない存在です.

この状況を改善するには,submoduleを使います.関係した処理をまとめて記述・管理するためにmoduleが導入されています.しかし,moduleでは,その中の変数やprocedureを変更すると全てコンパイルし直しになっていました.submoduleは,module内のprocedureを個別のファイルに切り出すことを許可します.関係するモジュールをまとめてフォルダへ格納し,その中でprocedureをサブモジュールとしてsubmoduleに切り出すのです.procedureのinterfaceを記述する必要性は生じますが,それによって変更した際の再コンパイル時間を短くするという利点を残しつつ,ファイル管理が煩雑化する欠点を解決できます.

main.f90
program main
    use :: mod_util,only: hello, doublify
    implicit none

    call hello()
    print *,doublify(2)
end program main
mod_util.f90
module mod_util
    implicit none

    interface
        module subroutine hello()
        end subroutine hello

        module function doublify(i) result(i2)
            integer,intent(in) :: i
            integer :: i2
        end function doublify
    end interface
end module mod_util
mod_util_hello.f90
submodule(mod_util) mod_util_hello
    contains

    ! module procedure hello ! <- functionかsubroutineかはinterfaceで記述済みなので,区別せずにmodule procedureとも書ける
    subroutine hello()
        implicit none
        print *,"hello"
    end subroutine hello
    ! end procedure hello
end submodule mod_util_hello
mod_util_doublify.f90
submodule(mod_util) mod_util_doublify
    contains
    ! module procedure doublify
    function doublify(i) result(i2)
        implicit none
        integer :: i
        integer :: i2

        i2 = i*2
    end function doublify
    ! end procedure doublify
end submodule mod_util_doublify

新しくモジュールmod_utilを導入し,hellodoublifymod_utilに含めました.hellodoublifysubmoduleに含めるには,それぞれ3行(submodul..., contains, end submodule...)をコピペするだけで終わります.それぞれのinterfacemod_utilに書いてあるので,function doublify(i) result(i2)ではなく,module procedure doublifyのように簡略化することもできます.

mod_utilはあくまで例です.関連のあるprocedureや定数をmoduleとしてまとめ,それらをフォルダに分けて整理することを意識してください.

短く,意図の読み取れない変数名

これは単純に古い規格の影響です.英単語の(母音を抜いた)省略形で解読が非常に困難です.変数名と物理量の対応が書かれたエクセルが存在する場合もありますが,更新忘れが頻発しており役に立ちません.

書き換える以外に改善することはできないのですが,いきなり書き換えると影響範囲が大きく,また暗黙の型宣言を使っている場合にはコンパイルエラーから参照箇所を確認するという小技も使えないので,procedure単位で書き換えます.書き換えの際にいくつか機能を利用できます.

別名参照は,別モジュールに存在している変数を,異なる名前で利用する機能です.use モジュール名, 別名=>基の変数名とします.

umag.f90
subroutine UMAG(U, U2)
    use param, only: N, UsualComputation=>JQWIOD, K_ON ! JQWIODを別名で参照
    implicit none
    real*4,intent(in) :: U(3,N), U2(3,N)
    real*4 :: mag

    if(UsualComputation == K_ON)then
        mag = computeVelocityMagnitute(U)
    else
        mag = computeVelocityMagnitute(U2)
    end if
    ! 以下省略
end

ポインタやenumを利用することもできます.ONやOFFは論理型で判断すべきでしょうが,ここでは一度enumをはさんでいます.

param.f90
module param
    use,intrinsic :: iso_c_binding
    implicit none
    integer,parameter :: N = 100
    real*4,target :: u(3,N)  ! target属性を付けてポインタ変数と結合できるようにする
    real*4,target :: u2(3,N) !

    ! 連番はenumに置き換える
    enum, bind(c)
        enumerator :: Disable=0
        enumerator :: Enable
    end enum

    integer*1 :: JQWIOD = Enable
end module param
umag.f90
subroutine UMAG(U, U2)
    use param, only: N, UsualComputation=>JQWIOD, Disable
    implicit none
    real*4,intent(in),target :: U(3,N), U2(3,N)
    real*4,pointer,dimension(:,:),contiguous :: velocity ! 2次元配列へのポインタで,メモリ配置はすべて連続
    real*4 :: mag

    ! 処理の呼出で分岐するのではなく,引数となる変数を条件に応じてポインタと結合する
    velocity => U
    if(UsualComputation == Disable) velocity => U2

    ! 以降の処理は分岐不要
    mag = computeVelocityMagnitute(velocity)
    ! 以下省略
end

自在に飛び回る行番号

FortranコードになくてFORTRANコードにあるのは,行番号です.かつては行番号が多く使われ,FORMAT文による入出力書式の統一,入出力エラーの簡潔な処理が実現されてきました.また.DO文を適当に書けるという(書き手からすると)利点もありました.しかし,必然的にGOTOが現れて処理の追跡が難しくなるといった問題が,特に適当に書かれたDO文で多く発生しました.また,行番号に規則性がなく,ラベルで置き換えられないのも,嫌われる一因でしょう.

Fortranでは,行番号を使わずにプログラムを書くことができます.エラー処理でgoto(それに付随する行番号)を使うかどうかは,設計の問題なのでここでは立ち入りません.

FORMAT文を参照するための行番号は,書式を文字列として取り扱うことで回避できます.書式を文字列定数として適切な場所に置いておけば,必要なprocedureからアクセスできます.

      X=10D0
      Y=20D0
      WRITE(*,100) X,Y
100   FORMAT(E10.1,E10.1)
    character(:),allocatable :: fmt
    real(8) :: x=10d0, y=20d0

    fmt = '(A,E10.1,A,E10.1,A)'
    print fmt, "position = (", x, ",", y, ")"

多重DO文の脱出には,名前付きdo構文とexitを利用します.反復回数を決めない汎用DO文はdo whileで置き換えられますが,汎用DO文が出てきたときは,そもそもプログラムを組む側が処理を理解していないことが多いので,設計の見直しが必要です.

      DO 20
      DO 20 I=1,2
      DO 20 II=1,2
          :
          :
          ERR = ...
          IF(ERR < 1D-9) GOTO 100
20    CONTINUE
100   CONTINUE
    real(8) :: error
    integer :: i,j

    error=huge(0d0)
    Convergence : do while(error > 1d-9) ! 反復回数の上限を定める場合は do iteration = 1, MaxNumIterationのようにする
        do j=1,N
            do i=1,N
                do ...
                   :
                   :
                   error = ...
                   if(error < 1d-9) exit Convergence ! 多重ループも一発で抜けられる
                end do
            end do
        end do
    end do Convergence

do while文を使うときは,最初の判定を通すために値を設定しなければならないので少々厄介です.

入出力エラーはiostat指定子の値に基づいて処理します.

      OPEN(UNIT=5,FILE="DATA.TXT")
      DO 200 I=1,10000
          READ(5,*,END=210) E
200   CONTINUE
210   CONTINUE
DataRead : do i = 1, 10000
    read(InputFileUnit,*, iostat = IOStatus, iomsg = IOMessage) e
    if(IOStatus > 0) then
        print *,IOStatus,trim(IOMessage) ! エラーメッセージはIOMessageに格納されている
        exit DataRead
    end if
end do DataRead

その他

上記の方法を使わずに局所的にコードを書き換える場合は,block構文を使って影響範囲を限定してください.FORTRAN/Fortranのプログラム単位は,変数の宣言を行う宣言部と,処理を書く実行部に分かれています.宣言部は実行部より先に置かれ,変数宣言は必ず宣言部で行わなければならなかったので,何かコードを修正する際にいちいちprocedureの最上部まで戻るのが面倒でした.それを理由に暗黙の型宣言を利用する人もいましたが,block構文が導入され,スコープを限定した局所変数を宣言できるようになりました.block構文の中で宣言した変数の寿命は当該のblock構文の中だけです.block構文の外で宣言した変数の参照も可能ですし,それらと同じ名前の局所変数を宣言して,block構文に外に影響を及ぼさずに処理することもできます.

    integer :: i = 1, j = 2
    print *,i,j        ! 1 2

    block
        integer :: j     ! block内変数jを新たに宣言(block外のjとは異なる)
        j = 1            ! block内のjに値を代入
        i = 2            ! block外変数iを参照できる
        print *, i, j    ! 2 1
    end block
    print *, i, j        ! 2 2

まとめ

FORTRANコードを書き直す際に役立つと思われるFortranの機能を紹介しました.これらを用いる事で,影響範囲を限定しながら,局所的にコードを置き換えていくことができます.これらの機能を利用できないほど古いコンパイラの利用を強制されている場合は,逃げた方が賢明です

本記事では機能を紹介しましたが,最も重要なことは,自分以外のチームメンバーの意識改革です.改革というと大げさに感じますが,まずは,心の奥底ではなんとなく修正が大変だと思っているその意識を自覚してもらうところから始めなければなりません.FORTRANのプログラムが最高に書きやすくて保守管理しやすいと思っている人は,いないんじゃないかと思っています.

イソップ寓話の北風と太陽は,非常によい教訓を教えてくれます.これはダメだと大上段から否定されて,「なるほどダメなのか.では今日から生まれ変わります」となる人はいません.こちらが強い言葉で否定すればするほど,相手は耳も心も閉ざします.著者も一時期そのように強気に出ていましたが,FORTRANプログラムやそれらを書いている人を非難したいわけではなく,保守管理しやすい環境を作り,効率よく業務を進めることが目的でしたので,太陽作戦に切り替えています.今ではかなり耳を傾けてもらえ,自身の業務の責任範囲では効率的にやらせてもらっています.ここで成功例を作り,取り組みをチーム全体に広げるのが当面の目的です.

新しい言語が好きな方は,数値計算に適した開発スタイルの開拓に注力してください.そして,その成果を自慢してください.まだFortranで疲弊してるの?と.著者はそこから新たに学ぶことができます.

44
39
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
39

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?