記事
· 22 hr 前 17m read

ObjectScriptで列挙体Likeなデータ型クラスを作ろう参加者

最初のご挨拶

コミュニティの皆さま、初投稿になりますが、何か少しでも興味深い知見を共有できると幸いです。

今回の内容は、筆者が%Persistentを中心に据えたデータ構造管理の検討の過程で必要性を感じ行った、「列挙体」Likeな「データ型クラス」(%DataTypeのサブクラス)構築に関するレポートです。

内容面では、筆者が「データ型クラス」の特性に不勉強だったことに由来しての躓きに関するものも多くなりますが、ご容赦願います。
また、内容の中には、筆者が思い当たらなかった手段の活用により、よりシンプルに回避できた部分もある可能性が大いにございます。
そういった内容にお気づきの場合、ご指摘いただけますと大変ありがたく存じます。

経緯

組み込みのデータ型クラスを利用した、プロパティ値への制約の表現の日常化

筆者が関与したDB層にIRISを採用するプロジェクトの多くでは、永続化されるデータの構造の定義に(ごく順当に)%Persistentが用いられていました。
また、この定義クラスの永続化対象のプロパティには、データ構造の仕様書の記載に対応させて以下のような型をあてがうケースが数多く見られました。

  • 最大値・最小値を観念(MAXVAL, MINVAL, SCALE具体化)した数値型
    • ex: %Integer, %Numeric
  • 最大長を観念(MAXLEN具体化)した文字列型
    • %String
  • 日時型
    • %DataTime

特にその中でも実質的に「列挙体」に当たる存在との折り合いの付け方

そして、このうち「最大長を観念した文字列型」として、

プロジェクト内では特定の意味合いを持ったいくつかの文字列のうちいずれかの値を取る

という性質のものが頻繁に登場します。
説明上の架空の一例として、下記を挙げます。

  • ホテルの部屋のベッドの配置区分
    • 1S: シングル - 1名用1台
    • 2D: ダブル - 2名用1台
    • 2T: ツイン - 1名用2台
    • 3T: トリプル - 1名用3台

これらの値はデータ構造の仕様書に参考情報として記載されており、実装上は対応するプロパティはMAXLEN = 2指定の%Stringとして定義されます。
そして、そのデータを扱う(≒参照・更新するコードを記述する)開発者が、

  • この4値がそれぞれ有機的に区別できる意味合いを帯びている
  • 一方で、それ以外の「2文字以内の文字列」と言えるもの全てがその文脈では意味を持たない
    • 本来設定されるべきですらない

認識した上で取り扱います。つまり、

データ構造の定義上表されたプロパティ値に対する制約は実質的な制約よりも緩いが、そのズレを開発者の認識で補う

という運用を取りがちな印象があります。

なお、この運用を補助する趣旨の手段として、以下のようなマクロ定義を行うような運用も見られます。
ただ、あるデータ構造が持つリテラルプロパティの定義とマクロの定義は本来的に独立のものなので、その両者を関連付けて見るべきということ自体がやはり開発者の認識に頼ったものではあります。

#Define RoomBedTypeSingle "1S"
#Define RoomBedTypeDouble "2D"
#Define RoomBedTypeTwin "2T"
#Define RoomBedTypeTriple "3T"

この実態が生む結果を見る中で、あるデータ構造が持つリテラルプロパティへの制約をより明確に再現性のある形で表現できる性質の、自作のデータ型クラスの定義が望ましいのではないかと感じたことが本件の発端となります。
つまり、根源的な欲求は、RDBにおけるCREATE DOMAINと言えます。

前提知識の確認と共有

今回の検討の中では、ObjectScriptでのデータ構造の定義に関連するいくつかの基本概念や技術要素の理解が必要となりました。
これらについては、公式ドキュメントを読み込むに勝るものはないことが予測されるため、今回は引用に留めさせていただき、本レポートに強く関係する点のみを補足的に記載致します。
一読していただくことで、本レポートの後続をより解像度高く読んでいただけるものと思われます。

リテラルプロパティ

https://docs.intersystems.com/iris20251/csp/docbookj/Doc.View.cls?KEY=GO...

  • オブジェクトクラスに定義できる「リテラル値を保持し、データ型クラス(後述)に基づているプロパティ」の総称

プロパティメソッド

https://docs.intersystems.com/iris20251/csp/docbookj/DocBook.UI.Page.cls...

  • オブジェクトクラスが持つ各プロパティに関連して(多くの場合自動的に)生成されるメソッド群
  • その生成の源泉は大きく2つあり、開発者がその在り方を定義できるのは、後者のみ
    • プロパティ動作クラス(開発者レベルで再定義不可
    • プロパティの型として指定されたデータ型クラス(開発者レベルで定義

データ型クラス

https://docs.intersystems.com/iris20251/csp/docbookj/DocBook.UI.Page.cls...

  • ClassType属性にdatatypeを指定されたクラス
    • 基本的に%DataTypeのサブクラスとなる
  • データ型クラスのパラメータは、特定のプロパティに対してオーバーライドできる
    • Property PatientId(MAXLEN = 10)もまさしくこの一例である
      • 実質的には、%Stringをベースに、新規の型をリテラルプロパティの定義ごとに再定義しているような実態
    • これはオブジェクトクラスにおいて、パラメータの定義が、そのクラス自体を継承することでしか変更できない点と対照的である
  • コンパイラがプロパティの動作を定義するために使用する、特定のメソッドのセット (通常はメソッド・ジェネレータ) を定義する
    • 非常に重要なのは「あるデータ型クラスの実装が、それをリテラルプロパティの型として使用するオブジェクトクラスの実装から利用される」ではない点
    • 実際には「オブジェクトクラスのコンパイル時点で、以下2点を統合したスナップショット的実装が生み出され、次のコンパイルまでは一貫してそれが利用される」
      • プロパティの型としてデータ型クラスの実装
      • データ型クラスのパラメータ(のプロパティレベルでのオーバーライドも踏まえた)値
    • このいわば「オブジェクトクラスのコンパイル時点でのデータ型クラスのスナップショット的取り込み」に由来して、以下のような特殊性が出る
      • データ型クラスの定義内で記した、自クラスメンバの参照(..MethodName, ..#ParameterName)は、リテラルプロパティに関連するロジックとしての動作時はデータ型クラスそのものでなく、そのプロパティを持つオブジェクトクラスを指すものとなる(以下、特殊性P
      • あるデータ型クラスAをリテラルプロパティの型とするオブジェクトクラスXのコンパイル後にAの定義を変更してコンパイルしても、Xの動作は古いAに基づくものであり続ける(以下、特殊性Q

メソッドジェネレータ

https://docs.intersystems.com/iris20251/csp/docbookj/DocBook.UI.Page.cls...

  • コンパイル時点で実行されるコードにより、あるメソッドの実装(ボディ部)を動的に定義する仕組み
  • あくまで動的に生成可能なのはボディ部であり、シグニチャレベル(メソッド名やその引数、返値の型)の動的な定義は行えない
  • 生成結果を空の実装とすることで、メソッド自体をサブクラス(ないしデータ型クラスを取り込んだオブジェクトクラスの実装)から削除することができる

実検討

筆者が列挙体に求める性質

  1. ある文脈における有限個数の取りうる値群(以下、列挙子)を表現できること
  2. ある値が列挙子の1つと言えるかのバリデーションを行う手段を提供すること
  3. 各列挙子に、それを意味上自然と想起させる識別子でアクセスできること
  4. その列挙子の並びを一元的に定義でき、その定義の変更が、既存のその列挙体を参照する文脈(≒データ型として指定したプロパティ)に作用すること

IRIS組み込みの%EnumStringの使用検討

ObjectScriptの組み込みの型として、上記の「求める性質」に近い内容を持つものとして、%EnumStringがあるため、これをまず検討しました。
この型を、前段落の記述と対応付けると、

  • 「列挙子」はVALUELISTパラメータ内で表現される
  • 「個々の列挙子の意味合いを表す自然な識別子」に当たる名称はDISPLAYLISTパラメータ内で表現される

ことになります。
具体的には下記のような実装が見込まれます。

Class DataType.RoomBedType Extends %EnumString
{

Parameter VALUELIST = ",1S,2D,2T,3T";

Parameter DISPLAYLIST = ",Single,Double,Twin,Triple";

}

ただ、まずDISPLAYLISTは内に含まれるものはObjectScript実装内で利用できる識別子とはなりません(3の不充足)。
また、ObjectScriptクラスのリテラルプロパティのバリデーションロジックは、特殊性Qにより、データ型クラスの定義自体の更新(ex: 列挙子の増減があった)に自動的に追従することができません(4の不充足)。

以上から、筆者として作成したいものを得るには、何らか別のアプローチが必要という結論に至りました。

1, 3に関する方針

Javaにおける列挙体の在り方などは示唆的ですが、列挙子に当たるものは、列挙体を表すクラスの静的メンバーとして表現することが穏当と考えました。
ただし、ここでObjectScriptの特色として、静的文脈に属すると言えるクラスメンバが、そもそもメソッドとパラメータしか存在しません。
静的フィールドないしプロパティが観念できないためです。

また、先述の通り、データ型クラスにおけるパラメータは、個々のリテラルプロパティの定義上オーバーライドしてしまうことができます。
仮にパラメータ定義にFinal修飾を付したところで、それはそのデータ型クラスの継承時のオーバーライドへの保護にしかならない点は実際に検証し確認できました。

そこで、自身が求める列挙体の在り方を実現する上での列挙子の表現は、(消去法的に)静的メソッドとするしかないとの結論に至りました。

2に関する方針

これは、データ型クラス基底である%DataTypeが静的メソッドIsValid(%rawString) As %statusを持つため、これを適切に定義すれば自然と解決できそうです。
ただし、列挙子は静的メソッドとして表現されるため、「列挙体」型基底として一般化できるものを定義するためには、何らかの「静的メソッドIsValidに対して、具象型毎に個数や名称の異なりうる列挙子を表現する静的メソッド群を紐づける」手段が必要となります。
これに関しては、一定の規約を設けた上で、データ型クラス自身の定義情報をメタ的に見ることで実現することとしました。
ただし、メソッドジェネレータの動作自体が個人的には難解であり、また、それと独自都合でのメタ的な定義のバランスが難しく非常に苦戦した点となりました。

4に関する方針

特殊性Qを前提として、これを満たすには、列挙子のバリデーションロジック自体をオブジェクトクラスとの関係で外在化させる必要があると判断しました。
これを、特殊性Pに左右されずに列挙体クラス内で実現する手段について苦慮することとなりました。

実装

過程に関する記述は割愛し、具体的な成果物についてコメントさせていただきます。

DataType.Base.FqcnGettable

  • リテラルプロパティのバリデーションロジックとしてデータ型クラスの実装を参照させることの実現の便宜のためにやむを得ず導入
  • 具象列挙体クラスの上でも、非ジェネレーターメソッドとして、これの一見冗長な具体化を行う必要があり、できれば解消したい点
/// Has the class method to get fqcn of datatype class itself
Class DataType.Base.FqcnGettable [ Abstract ]
{

ClassMethod GetFqcn() As %String [ Abstract ]
{
}

}

DataType.Base.StringWithStickyValidation

  • %Stringが元々備えているバリデーションを残しつつ、列挙体のような「独自の」「パラメータ非依存」のバリデーションの適用を確保する文字列の基底として定義
  • IsValidDTは組み込みのデータ型クラス内で想定される特殊バリデーションロジックのようであるため踏襲
    • ただし、組み込みのデータ型クラスでは、IsValidDTが実装されている場合には、それによるバリデーションしか行わないようになっており、この点の判断は現時点でも明確な結論を得ていない
/// %String with validation independent of the parameters defined in %String level
Class DataType.Base.StringWithStickyValidation Extends %String [ Abstract ]
{

Parameter MAXLEN;

ClassMethod IsValid(%val As %RawString) As %Status [ CodeMode = generator, Final, ServerOnly = 0 ]
{
    #; This datatype is validated by IsValidDT and also %String normal validation (This differs from %String).
    $$$GENERATE("    Quit:('##class("_%class_")."_$$$QN(%property_"IsValidDT")_"(%val)) $$$ERROR($$$DTFailure,%val)")
    #; Validate MAXLEN parameter - either NULL, "", or a positive integer
    If ($Get(%parameter("MAXLEN")) '= "") && (('$IsValidNum(%parameter("MAXLEN"),,1)) || (((+%parameter("MAXLEN")\1)) '= +%parameter("MAXLEN"))) { Quit $$$ERROR($$$DatatypeParameterIntegerNotPositive,%class_"::"_%property,"MAXLEN",%parameter("MAXLEN")) }
    If %parameter("VALUELIST")'="" Do   Quit $$$OK
    . Set sep=$Extract(%parameter("VALUELIST")) ;for now
    . $$$GENERATE("    Q $s(%val'["""_sep_"""&&("_$$$quote(%parameter("VALUELIST")_sep)_"[("""_sep_"""_$select(%val=$c(0):"""",1:%val)_"""_sep_""")):$$$OK,1:$$$ERROR($$$DTValueList,%val,"_$$$quote(%parameter("VALUELIST"))_"))")
    Set str="",err=""
    If %parameter("MINLEN")'="" {
        Set str=str_"($s(%val'=$c(0):$l(%val),1:0)'<"_(+%parameter("MINLEN"))_")"
        If %parameter("PATTERN")="",%parameter("MAXLEN")=""||(%parameter("TRUNCATE")) {
            Set err="1"
        } Else {
            Set err="$s(%val'=$c(0):$l(%val),1:0)<"_(+%parameter("MINLEN"))
        }
        Set err=err_":$$$ERROR($$$DTMinLen,%val,"_(+%parameter("MINLEN"))_")"
    }
    If '%parameter("TRUNCATE"),%parameter("MAXLEN")'="" {
        Set str=str_"&&($l(%val)'>"_(+%parameter("MAXLEN"))_")"
        If %parameter("PATTERN")="" {
            Set err=err_$Select(err="":"",1:",")_"1"
        } Else {
            Set err=err_$Select(err="":"",1:",")_"$l(%val)>"_(+%parameter("MAXLEN"))
        }
        Set err=err_":$$$ERROR($$$DTMaxLen,%val,"_(+%parameter("MAXLEN"))_")"
    }
    If %parameter("PATTERN")'="" Set str=str_"&&(%val?"_%parameter("PATTERN")_")",err=err_$Select(err="":"",1:",")_"1:$$$ERROR($$$DTPattern,%val,"_$$$quote(%parameter("PATTERN"))_")"
    If str="" $$$GENERATE("    Q 1") Quit $$$OK
    If $Extract(str,1,2)="&&" Set str=$Extract(str,3,*)
    $$$GENERATE("    Q $s("_str_":1,"_err_")")
    Quit $$$OK
}

/// Validation independent of the parameters defined in %String level
ClassMethod IsValidDT(%val As %RawString) As %Status [ Abstract ]
{
}

}

DataType.Base.Enum

  • 列挙値群を得る静的メソッドGetValuesとその値群に含まれるかによるバリデーションを一般化する「列挙体基底」クラス
  • GetValuesに関しては、データ型クラス自体の定義時に以下のAND条件を満たすメソッドを列挙子を表現するメソッドとみなす規約主義で実装
    • 静的メソッド
    • 引数無し
    • 返値の型が自分自身である
  • IsValidDTの実装により、(リテラルプロパティの型として列挙体型を利用した)オブジェクトクラスから列挙体型自体の実装が利用されることをかなり作為的に実現している状況である
/// Enum core implemention
Class DataType.Base.Enum Extends FqcnGettable [ Abstract ]
{

/// Get a %List of enumerator values.
ClassMethod GetValues() As %List [ CodeMode = objectgenerator, Final ]
{
    // If %member is not empty string, this method is evaluated as literal property method in an object class.
    // This method's implemention is meaningless in the case.
    Return:(%member '= "") $$$OK

    Set nqcn = $Piece(%class.Name, ".", *)
    #Dim methods As %Library.RelationshipObject = %class.Methods
    Set methodKey = ""
    #Dim methodNames As %List = ""
    While 1
    {
        #Dim method As %Dictionary.CompiledMethod = methods.GetNext(.methodKey)
        Quit:(methodKey = "")
        Continue:'(method.ClassMethod && (method.ReturnType = nqcn) && (method.FormalSpec = ""))
        Set $List(methodNames, $ListLength(methodNames) + 1) = method.Name
    }
    Do %code.Write("  Return $ListBuild(")
    For i = 1 : 1 : $ListLength(methodNames)
    {
        Do %code.Write(".."_$ListGet(methodNames, i)_"()")
        Do:(i '= $ListLength(methodNames)) %code.Write(", ")
    }
    Do %code.Write(")")
    Return $$$OK
}

ClassMethod IsValidDT(%val As %RawString) As %Status [ Final ]
{
    Return $ListFind($ClassMethod(..GetFqcn(), "GetValues"), %val) > 0
}

}

DataType.Base.StringEnum

  • 上述のStringWithStickyValidationEnumをmix-inするだけの層
    • 列挙子値を文字列に限らない余地を残した関係上、必要となった層
/// Enum with %String enumerator value base class
Class DataType.Base.StringEnum Extends (StringWithStickyValidation, Enum) [ Abstract ]
{

}

DataType.StringEnum.RoomBedType

  • DataType.Base.StringEnumの具象サブクラス例
  • GetFqcnの冗長さに関しては上述の通り
  • 列挙子を表現するメソッドは、この型をリテラルプロパティの型として採用するオブジェクトクラスにとっては無用であるメソッドが生成されることを防ぐためだけにジェネレータメソッドとなっており、これにより、記述が一見複雑になっている
  • いずれのメソッドも機械的に記述できる内容しか持たないため、より簡潔な列挙体定義データからプログラムを通して生成することが想定される
/// Room's bed type
Class DataType.StringEnum.RoomBedType Extends DataType.Base.StringEnum
{

ClassMethod GetFqcn() As %String [ CodeMode = expression ]
{
"DataType.StringEnum.RoomBedType"
}

/// Single
ClassMethod Single() As RoomBedType [ CodeMode = objectgenerator ]
{
    Return:(%member '= "") $$$OK
    Do %code.WriteLine("   Return ""1S""")
}

/// Double
ClassMethod Double() As RoomBedType [ CodeMode = objectgenerator ]
{
    Return:(%member '= "") $$$OK
    Do %code.WriteLine("   Return ""2D""")
}

/// Twin
ClassMethod Twin() As RoomBedType [ CodeMode = objectgenerator ]
{
    Return:(%member '= "") $$$OK
    Do %code.WriteLine("   Return ""2T""")
}

/// Triple
ClassMethod Triple() As RoomBedType [ CodeMode = objectgenerator ]
{
    Return:(%member '= "") $$$OK
    Do %code.WriteLine("   Return ""3T""")
}

}

.intレベルでのメソッドジェネレータやプロパティメソッドの解決結果の確認

より直感的にイメージを持っていただくために、上記のクラスの一部について、intレベルでどのような実装となるかも掲載致します。

#; DataType.StringEnum.RoomBedType
Double() methodimpl {
   Return "2D" }
GetFqcn() methodimpl {
    Quit "DataType.StringEnum.RoomBedType" }
GetValues() methodimpl {
  Return $ListBuild(..Double(), ..Single(), ..Triple(), ..Twin()) }
IsValid(%val) methodimpl {
    Quit:('##class(DataType.StringEnum.RoomBedType).IsValidDT(%val)) $$Error^%apiOBJ(7200,%val)
    Q 1 }
Single() methodimpl {
   Return "1S" }
Triple() methodimpl {
   Return "3T" }
Twin() methodimpl {
   Return "2T" }
#; DataType.Demo.Room内
BedTypeGetFqcn() methodimpl {
    Quit "DataType.StringEnum.RoomBedType" }
BedTypeGetStored(id) methodimpl {
    Quit $Select(id'="":$listget($g(^Demo.RoomD(id)),2),1:"") }
BedTypeIsValid(%val) methodimpl {
    Quit:('##class(Demo.Room).BedTypeIsValidDT(%val)) $$Error^%apiOBJ(7200,%val)
    Q 1 }
BedTypeIsValidDT(%val) methodimpl {
    Return $ListFind($ClassMethod(..BedTypeGetFqcn(), "GetValues"), %val) > 0 }

クラス構成図

クラス図

特殊性Qの影響を逃れていることのデモ

下記動画では、DataType.StringEnum.RoomBedTypeをリテラルプロパティの型とする%PersistentクラスDemo.Roomのインスタンスの保存(に際して行われるバリデーション)の挙動を通して、「オブジェクトクラス側の再コンパイルを伴わずとも、列挙体クラスの定義更新が動作に影響している」状況をデモンストレーションしています。

リポジトリのご紹介

本レポートで扱った内容を下記リポジトリに反映しております。
ご興味がある方はご覧ください。

最後のご挨拶

振り返ってみても、スタートの欲求の時点で、正しい方向を向けているのかを自身でも疑問視しています。
また、最終的な実現も、ObjectScriptがデータ型クラスに対して想定する在り方を捻じ曲げようとする、いわゆる黒魔術的な側面を帯びた印象は否定できません。
ただ、その定義を可能にする言語機能面の柔軟性や、その黒魔術的実装と組み合わさっての正常な動作実現を可能にする、プロパティレベルでの(バリデーション等の)自動メソッド生成や実行の仕組みの堅牢性により、自身の挑戦が一定の形を得たことに関しては、感謝に堪えません。

個人的には、IRISを利用し巨大で多様なデータ群を管理する上で、%Persistentの積極的な活用は必須の認識であるため、今後もそれに関連する様々な実践・工夫について探っていくことになる見込みです。
今回はライティングコンテストと言うこともあり、ある程度の分量を記しやすそうな内容を選ばせていただきましたが、他の検討内容についても、また機会を見て本コミュニティに投稿させていただき、他開発者の方からお知恵を借りられればと改めて感じます。

まとまりに欠く部分もあり、またそもそもニッチな内容であったかと思われますが、ここまでご覧いただきありがとうございました。

ディスカッション (0)1
続けるにはログインするか新規登録を行ってください