🍽️

CASE文によるストラテジーパターン

2024/12/10に公開

はじめに

本記事は、主にPLC(Programmable Logic Controller)向けのソフトウェア開発に従事、または関心のある方で、Structured Text(ST)による開発に興味がある方向けです。

CASE文の応用として、ストラテジーパターンを展開します。一般的な言語ほどの柔軟性はありませんが、単一制御コードの肥大と複雑化を軽減し、複数制御コードの併存と実行時選択を容易に行えるようになります。また、リファクタリングのツールにもなります。CASE文によるストラテジーパターンは以下のような構造です。

CASE StrategyDetector(DetectInfo[,Context]) OF
    StrategyA: StrategyA(Context[,OtherElements]);
    StrategyB: StrategyB(Context[,OtherElements]);
    ...
ELSE
    InvalidStrategy(Context);
END_CASE;

構造としてはただのCASE文とFUNでしかありません。適切な使用には、IOとストラテジーを確実に分離するContextの定義とHAL(Hardware Abstraction Layer)の導入が必要です。

課題

一般にLDで実装したソフトウェアであれば、セクション分けした複数のPOU/プログラムと部分機能制御や演算を実装したFB、FUNで構成していると思います。あるいは、単一の肥大したPOU/プログラムだけというのが実際かもしれません。一方、制御対象となる装置は、モデル更新で必要な制御コードが増えていきます。新モデルが単一ではなく、僅かな差異のある複数となれば必ずしも全ての装置で使用しない実行コードを含むようになります。また、既存モデルと同様の機能を維持することも求められます。既存モデルの機能を維持しての電装品一式の置換では、電装品の入手ができなければ、現在の最新ソフトウェアが適用できない限り再開発に近いものとなるためです。このような状況に対して、先のように構成するソフトウェアで対応すると以下のいずれかの手段で実行コード制御を行うことになります。

  1. 接点(フラグ)の導入
  2. フラグとJMPまたはMC/MCRの組み合わせ
  3. POU/プログラムの切り替え

1は、導入箇所が少数であれば問題無いと思うかもしれませんが、条件分岐が1つ増えることになるので確実にコードの複雑さが増します。また、得てして導入を忘れる箇所、誤った位置への導入、制御上の衝突が生じて接点の濫造が生じます。2は、1で対処できない場合に取る手段であるため、より深刻です。関連するコード全体をコピーして少し改変し、どちらかを実行するとした場合、単一POUに膨大なコードの重複が生じます。3は、2をより深刻にしたものでコードの重複が多すぎるか、機能的に分割した多数の小さなPOU/プログラムが生じます。

3は機能の取捨選択で済みますが、1と2は継続的な開発自体を不可能にする可能性を持っています。特に1は軽微で当たり前のように行われていると思いますが、少しずつの蓄積が最終的に大規模なリファクタリングを必要とするほどの複雑さを容易に生じさせます。一つの接点は、一つのIF文です。1-3は制御構文または、コントローラ機能の使用による方法で、実行コード制御に対する適用は当然であり、妥当でもあるのですが問題を生じてしまうのです。

1-3以外、一連の制御コードをFUNやFB内に展開して実行するような構成で対応していれば、ストラテジーパターンに近い指向で実装している可能性もあります。しかしながら、IOとの結合が強いようであれば問題があります。IOと制御コードの密結合は、機能の維持を困難にします。この密結合は、1-3であればほぼ当然のように見られます。構成できるIOデバイスの個数には限界があり、機能的な差異によって構成が異なると変更することになります。IOデバイスの無効化だけでしのげるとは限りません。その際、変数はIOに対する割り付けだけを解除して残しておくこともできますが、再構成に失敗するのは目に見えています。

Sysmac StudioであればGitオプションを使用してバージョン管理を行うこともできますが、それが可能な開発チームであれば、ソフトウェアのアーキテクチャも洗練されている可能性が高いでしょう。経験則ではありますが、コンウェイの法則もそれを支持することになります。ライブラリ化も同様です。そもそもライブラリ化できるような状態である時点で、十分に洗練されている可能性があります。

CASE文によるストラテジーパターン

CASE文によるストラテジーパターンは、以下のような構造になります。

CASE StrategyDetector(DetectInfo[,Context]) OF
    StrategyA: StrategyA(Context[,OtherElements]);
    StrategyB: StrategyB(Context[,OtherElements]);
    ...
ELSE
    InvalidStrategy(Context);
END_CASE;

CASE文の条件式でストラテジー決定FUN(StrategyDetector)を評価し、節でストラテジーFUNを、必要な一連の情報を集約したContextを渡して実行します。ストラテジー決定FUNでContextを引数とすることで、あるストラテジーの実行結果として異なるストラテジーを選択するという構造にすることもできます。

CASE文によるストラテジーパターンは構造的なフレームを提供するだけなので、StrategyDetectorとStrategyのロジックは開発者が検討しなければなりません。しかしながら、フレームが提供されることで考え方や実装方針について一定の流れができるようになります。Contextをどのようにするかも検討が必要です。必ずしも全てを集約するのではなく、主たる制御対象とそれに従属的に関連する対象だけを含め、ストラテジーによって使用するか否かが分かれるものはパラメータとして分けて明示するほうが良いです。オプションとして補助的なセンサを使用するようなストラテジーであれば、それをパラメータとして渡す等です。単一のContextにこだわると肥大して多くのストラテジーで不要なメンバーを抱えることになります。

Contextの使用は、HAL(Hardware Abstraction Layer)が前提になります。組み込み分野でのHALほど厳密で議論を要するものではなく、制御コードとIO間の緩衝材としてのレイヤーと認識したほうが妥当かもしれません。あるいは、ヘキサゴーナルアーキテクチャのアダプターとポートのような役割との認識でもよいです。その役割は、IOと制御コードの信号交換です。IOと制御コードの分離は重要なトピックなので、それだけで内容を検討することが必要です。いわゆる接点としてのリモートIOから、IO-Link等による非BOOLな値のやり取りが必要なデバイスへの移行によってその必要性は増しています。IO-Linkデバイスからのデータを接点化しているコードがあれば、その部分は実質的にHALの役割を果たしていると見なせます。

CASE文によるストラテジーではFUNの使用を推奨しますが、モーション制御を含むようであればFBを使用します。どちらも外部変数の使用はシステム定義変数に留め、パラメータによって振る舞いが決定するようにします。各FUNのパラメータは、必要に応じて変更します。必要な事は、IOとFB、FUNを分離するのに十分なパラメータとすることです。パラメータは、IOに割り付けた変数を渡すという方法も許容されるかもしれません。それは、HALの定義次第です。HAL内として定義された名称の変数をIOに割り付けることが許容されるなら問題ありません。しかし、IOにはその識別が可能な変数を割り付け、別途実行コードでHALの変数に転送するということであれば許容されないことになります。

IOとはいっても接点が殆どというのであれば、有意味な名称の変数の定義という時点で十分抽象化されていると考えることが妥当です。そうであれば、いわゆるIOマップでの割り付けで済ませることになります。IOマップでIOと変数の対応は十分把握でき、必要な確認も行えるためです。延々とBOOL値をコピーするコードに意味はありません。しかし、IO-Linkデバイスからのデータのように適当な構造体や演算処理が必要なIOが増えるのであれば、コードとしてのHALも必要になります。

CASE文によるストラテジーパターンは、課題に対してどのように対処するのでしょうか。ストラテジー内のコードは、そのストラテジーにおいて必要なコードのみで構成されることになります。これにより、不要なコードを抑制するためのコードが不要になります。また、ストラテジーの前提が満たされていることを保証すれば、防御的なコードを減らすことになります。コードの重複は存在しますが、それは必要な重複です。安定した高い凝集をもたらすことになります。IOとContextの分離によりストラテジーはIOとも疎結合であるため、必ずしもプロジェクト内に残しておく必要はなく、ライブラリ化して分離することでコードベースを一定サイズに保つこともできます。これにより、多数の派生制御コードが生じたとしても適切に管理する手段があることになります。ストラテジーは十分に凝集しているため、既存機能の保持にも効果があります。もちろん、装置が対応できる限りにはなります。

構築例

3ノッチセレクタをStrategyDetectorとして電動機の動作モード選択に適用してみます。まず、LDによる少し変則的な3ノッチセレクタから始め、STによる3ノッチセレクタをFUNとしてモード選択に使用できるようにします。次に、セレクタをそのまま動作モード選択に使用し、最後にそれを拡張します。Contextの定義とHALレイヤーの実装は省いています。回転方向を変えられるON/OFFだけの汎用の電動機とすれば、HALレイヤーは高々数点のBOOL値をIOと交換するだけです。入力はトリップ、運転許可と運転指示、出力は動力ON/OFFと回転方向に応じた出力ぐらいでしょうか。

LDによる3ノッチセレクタ

以下は、モード選択として機能するセレクタです。一般的な3ノッチスイッチとは遷移が異なります。


POU/プログラム/LD_ThreeNotchSel

Inの立ち上がりによって、Out1-Out2-Out3-Out1...と出力が切り替わります。組み込み命令を使うとシンプルになりますが、接点縛りのLDとしました。Outが遷移順に対して逆で不自然かもしれませんが、立ち上がりを入力として状態を切り替える構造を、余計な接点を導入しないで構成することができます。

STによる3ノッチセレクタ

以下は、LDのセレクタと同じ振る舞いのSTコードです。構造的に状態遷移部と信号出力部に分かれています。

POU/プログラム/ST_ThreeNotchSel
// 状態遷移部
IF In AND NOT InPrev THEN
   Selected := (Selected + 1) MOD 3;
END_IF;
InPrev := In;

// 信号出力部
CASE Selected OF
    0: Out3 := FALSE; Out1 := TRUE;
    1: Out1 := FALSE; Out2 := TRUE;
    2: Out2 := FALSE; Out3 := TRUE;
ELSE
    Out1 := FALSE; Out2 := FALSE; Out3 := FALSE;
END_CASE;

コードが冗長に思えるのは仕方がありません。LDではOutを状態遷移に使用していたのでOutが必須だったのですが、STでは使用しないためです。STでもOutを使用した実装とするこはできますが、例だとしても採用を許容できないものになります。逆に、この構造のLDによる実装は、許容できる場合もありますが、接点だけでは済まなくなります。このSTの効用は、ロジック上重要な部分をあぶり出せたことです。入力による状態遷移自体は少しのコードで十分ということであり、セレクタにおいて信号出力は副次的なものだということです。

ThreeNotchSel

以下は、状態遷移部だけを取り出してFUN(ThreeNotchSel)にしたものです。戻り値として選択値を返すことが重要です。

POU/ファンクション/ThreeNotchSel
IF In AND NOT InPrev THEN
    Selected := (Selected + 1) MOD 3;
END_IF;
InPrev := In;

ThreeNotchSel := Selected;

見かけ上は、ある上限値までのカウントアップを繰り返すカウンタです。実際にはカウンタで構成することもできますが、カウンタ命令はFBなので使い勝手がかなり変わってしまいます。

動作モードセレクタ

電動機の動作モード選択を例としてThreeNotchSelを使用してみます。電動機の動作を非停止中に切り替えることはまず行わないため、停止中にのみ実行することが前提です。以下は、ThreeNotchSelとCASE文を組み合わせた電動機の動作モードセレクタです。

POU/プログラム/MotorCtrlByNum
CASE ThreeNotchSel(In:=M1ModeChange,
                   Selected:=Selected,
                   InPrev:=InPrev) OF

    0: MotorCtrlStop(Motor:=M1);
    1: MotorCtrlCCW(Motor:=M1);
    2: MotorCtrlCW(Motor:=M1);
ELSE
    MotorInvalid(Motor:=M1);
END_CASE;

ThreeNotchSelをCASE文の条件式で使用し、動作モードを選択します。各節のFUNは、電動機の現在の動作状態を変えるのでもなければ、制御コード内での動作選択のためのフラグを設定するものでもありません。動作開始から停止までの一連の運転制御コードそのものです。各節のFUNの名称は一般的ですが、より意図の読み取れる名称にすることで可読性が向上します。また、使用するセンサや補助装置があれば、パラメータとして渡すようにします。そうすることで、何が関連しているかを示すことができます。

直接に動作制御を行うコードにこの構造を使用する場合、状態遷移に不正、不定が起きないかに注意する必要があります。1サイクルであっても不定状態にならないこと、1サイクルだけ許容される状態が複数サイクルに渡っていないか等です。

動作モードセレクタの拡張

先の動作モードセレクタは、ストラテジーの名称から何をやっているかは分かりますが、もう少し分かりやすくします。まず、以下の列挙型を定義し、モータの動作モード状態を明示します。


データ/データ型/列挙型

列挙型は、列挙値ゼロに何を割り当てるかが重要です。変数で初期値を割り当てない場合に評価される値になります。次に、以下の電動機の動作モードを返すFUNを定義します。

POU/ファンクション/MotorMode
IF NOT EN THEN
    MotorMode := MOTOR_MODE_INVALID;
	
    RETURN;
END_IF;

IF STO THEN
    MotorMode := MOTOR_MODE_STO;
ELSE
    NumToEnum(In:=In, InOut:=MotorMode);
END_IF;

入力値をMOTOR_MODE列挙型に変換するのに加え、STO信号が有効な場合は、常にSTOモードを返します。以下は、上記を組み合わせて電動機の動作モードセレクタを拡張したものです。

POU/プログラム/MotorCtrlByEnum
CASE MotorMode(
         STO:=fsSTO,
         In:=ThreeNotchSel(
                 In:=M1ModeChange,
                 Selected:=Selected,
                 InPrev:=InPrev)) OF
				  
    MOTOR_MODE_STO:
        MotorCtrlSTO(Motor:=M1);
        Selected := SEL_MOTOR_STOP;
				 
    MOTOR_MODE_STOP:
        MotorCtrlStop(Motor:=M1);
    MOTOR_MODE_CCW:
        MotorCtrlCCW(Motor:=M1);
    MOTOR_MODE_CW:
        MotorCtrlCW(Motor:=M1);
ELSE
    MotorInvalid(Motor:=M1);
END_CASE;

この拡張の目的は、列挙型によって動作モードを読み取れるようにすることとSTO状態の導入です。実質的なSTOはセーフティ側で行いますが、ノーマル側でも行うことはあります。例えば、運転開信号が入っても一切反応しないようにすることです。LDであれば、適当な接点を導入して動作しないようにすると思いますが、ここでは、そもそも動作のためのコードが一切ない状態にすることで反応しないことを担保します。また、STO解除後に動作モードが停止モードになるようにします。

ThreeNotchSelとMotorModeの値の対応には改善の余地があります。ThreeNotchSelの戻り値をMotorModeで使用可能な値へとマップするFUNを介するようにすることで、ThreeNotchSelとMotorModeの結合を軽減することができます。

最後に、以下にSysmacプロジェクトを置いておきます。

https://github.com/kmu2030/CaseStatement_StrategyPatternExample

まとめ

CASE文によるストラテジーパターンは、フレームを提供するだけですが、実装について一定の方向性をもたらします。本来、パターンの適用が有効であると見なせるから採用するのであって、パターンに従って検討するのは、アンチパターンです。また、適用するだけでその効果を十分に発揮できるものでもありません。しかしながら、実行コード制御について、直接的な制御構文の使用やプログラムの切り替え以外の手段があることは示せたと思います。

Discussion