執筆者が体験したユーザー定義派生型の使い方失敗5例
@soybean です.
ライブラリplantFEMをメンテする上で,様々な失敗を重ねてきましたので,その例と(当面の)対策について書きます.
そのいくつかは,よく知られたアンチパターンに結びついています.
【失敗1】派生型のIOを担うメンバ関数/サブルーチンが複雑になりすぎて,関数名が禁句扱いになる.
例えば,3次元オブジェクトを持つ派生型mesh
を以下のように定義します.
use iso_fortran_env
type mesh
real(real64), allocatable :: x(:), y(:), z(:)
contains
procedure, public :: import => importMesh
end type mesh
contains
! ここにimportMeshのプログラムを書く
!...
すると,importMesh
のようなインポーターを書きたくなりますが,3次元オブジェクトの入出力形式は多種多様ですので,必然的に内部で条件分岐を伴う複雑な実装を施すこととなります.
その結果,ナイーブに実装した場合,派生型のIOを担うメンバ関数/サブルーチンが複雑になりすぎて,保守性が低下します.
subroutine importMesh(this, fileName,VTK_reader_option1,VTK_reader_option2,&
VTK_reader_option3,..,PLY_reader_option1,PLY_reader_option2,PLY_reader_option3,... )
class(mesh) :: this
character(*),intent(in) :: fileName
! 読み込み時,拡張子ごとに異なる追加情報が必要な場合,optional引数が多数必要になる.
character(*),optional,intent(in) :: VTK_reader_option1,VTK_reader_option2,&
VTK_reader_option3,..,PLY_reader_option1,PLY_reader_option2,PLY_reader_option3...
! fileNameから拡張子を判定
! 拡張子ごとにif分岐を設ける.
if( extention(fileName)== 'vtk' ) then
call importMeshVTK(this, fileName,VTK_reader_option1,VTK_reader_option2,&
VTK_reader_option3,..)
elseif( extention(fileName)== 'ply' ) then
call importMeshPLY(this, fileName,PLY_reader_option1,PLY_reader_option2,&
PLY_reader_option3,...)
...
end if
end subroutine importMesh
【原因】場当たり的に追加したoptional
引数の多用により,入力クラッジ(input kludge)が発生している.
新しいファイル形式に対応するたびに安直に条件分岐とoptional
引数を増やした結果,内部の仕様を知らないと正しい引数の組み合わせがわからないことになっています.
【対策】機能の切り分けを行うとともに,必須かつ共通な引数をoptional
なしで必須の引数として宣言する.
個別の明確な機能をもつ個別の関数/サブルーチンに分割(importVTKFILE()
など)し,また不正な引数を入れられないように,引数の数を削減してかつ強制することが考えられます.なお,ここで
- optional引数を派生型のメンバ変数にして解決
- ``import
のかわりに
read```などと別の関数を作って対策
などとすると,事態が一層悪化することがあります.
【失敗2】1つのメンバ関数/サブルーチンに,無駄に複数の名前を割り当てる.
はじめ,import
を作った後で,他のライブラリではread
を遣っていると気が付き,さらに他のライブラリではread_file
を遣っていると気が付き...
全てのユーザーを呼び込もうとして多数の名前をつけてしまう危険性があります.
その結果,import
とopen
を切り分けたい時に変更が困難となってしまう等の弊害が発生します.
use iso_fortran_env
type mesh
real(real64), allocatable :: x(:), y(:), z(:)
contains
procedure, public :: import => importMesh
procedure, public :: read => importMesh
procedure, public :: open => importMesh
procedure, public :: import_file => importMesh
procedure, public :: read_file => importMesh
procedure, public :: open_file => importMesh
end type mesh
contains
! ここにimportMeshのプログラムを書く
!...
【原因】関数名決定時に優先順位を取り違えている.
関数名/サブルーチン名として,統一され,かつ意味が一意に特定できることが肝要であり,表面的な呼称を他ライブラリと合わせることはより優先順位が低いことですので,その点を明らかにして実装をすべきでした.
【対策】意味の似た動詞は,目的語とセットにして名称とするなどして,各関数/サブルーチンの役割を明確化する.また,幅広い意味にとれる単語は使用しない.
ライブラリごとに構造は異なるものですし,構造が類似していれば手続き名称が多少異なっても慣熟は早いものです.「手続き名を他ライブラリに合わせれば,新規ユーザーが直感的に使える」という幻想から,構造と機能の異なる手続きを同じものと誤解させるほうがユーザーを困惑させます.
【失敗3】 intent(in)
で十分なところを,intent(inout)
にしてしまう.
この失敗は,
- はじめ,
intent(inout)
で読み込むことを想定していたのが,実装を進める内にintent(in)
で十分であることに気がついたものの,なぜか放置された. -
allocated()
を使おうとして,「intent(in)
属性ではallocated()
を呼べない」などとコンパイルエラーが出て,安直にintent(inout)
とした.
といった経緯で発生しえます.
本手続きをpure
にし,do concurrent
内で呼ぶ際や,他のintent(in)
で受け渡す手続きで呼ぶ場合に問題が顕在化します.
【対策】intent(inout)
は,intent(in)
で済むように工夫する.
これに尽きますが,見落とした場合には,後から幾多の依存関係を整理しながらintent(inout)
をintent(in)
に変更するお仕事が生まれます.
【失敗4】String
, List
, Dictionary
等の型を自作し,保守しきれなくなり,特定の関数を呼ぶときの特殊な型としていつまでも残ってしまう.
他の言語の機能を真似て,基本的な型を自作した上で,実装当初は便利に感じてさまざまな関数/
サブルーチンで用いたとします.
しかし,よくあることとして,こうした低レベルな自作型は求める機能が多いため,保守コストを払いきれなくなり,やがて使わなくなります.
その結果として,「あるサブルーチンHogeを呼ぶ際にだけ必要な奇っ怪な型であるList型」のようなものが錬成されてしまい,リファクタリングに多大なコストを払うことになります.
【対策】stdlib等の外部ライブラリを積極的に活用する.
stdlib等の優れた外部ライブラリがありますので,それらを利用して車輪の再発明を抑制します.
stdlibについてはこちらの記事が詳しいです.
一方で,外部ライブラリへの依存度が高すぎるライブラリは,砂上の楼閣と呼ばれるアンチパターンに該当します.塩梅が難しい...
【失敗5】コンストラクタは定義したものの,デストラクタを定義し忘れてメモリが解放できなくなる.
初期化用の手続きとして,init
等のコンストラクタを定義することは基本的です.
しかし,インスタンスを1回呼ぶだけのテストコードを実行している場合には,うっかりデストラクタを定義し忘れることがあります.
すると,ループ処理などを書いた際に,一度立てたインスタンスがいつまでもメモリを専有したり,init
で再初期化しようとしてバグを生じたりといった失敗が生じます.
【対策】デストラクタはコンストラクタとセットで定義しておく.
当たり前ではありますが...
まとめ
派生型を使う場合にうっかり踏んだ失敗と,その対策をまとめました.
対策として,よりよい方法が気もしますので,詳しい方がおられましたらご指摘頂ければ幸いです.