ObjectScriptで列挙体Likeなデータ型クラスを作ろう
はじめに
コミュニティの皆さま、初投稿になりますが、何か少しでも興味深い知見を共有できると幸いです。
今回の内容は、筆者が%Persistentを中心に据えたデータ構造管理の検討の過程で必要性を感じ行った、「列挙体」Likeな「データ型クラス」(%DataTypeのサブクラス)構築に関するレポートです。
内容面では、筆者が「データ型クラス」の特性に不勉強だったことに由来しての躓きに関するものも多くなりますが、ご容赦願います。 また、内容の中には、筆者が思い当たらなかった手段の活用により、よりシンプルに回避できた部分もある可能性が大いにございます。 そういった内容にお気づきの場合、ご指摘いただけますと大変ありがたいです。
経緯
組み込みのデータ型クラスを利用した、プロパティ値への制約の表現の日常化
筆者が関与したDB層にIRISを採用するプロジェクトの多くでは、永続化されるデータの構造の定義に(ごく順当に)%Persistentが用いられていました。
また、この定義クラスの永続化対象のプロパティには、データ構造の仕様書の記載に対応させて以下のような型をあてがうケースが数多く見られました。
- 最大値・最小値を観念(
MAXVAL,MINVAL,SCALE等を具体化)した数値型- ex:
%Integer,%Numeric
- ex:
- 最大長を観念(
MAXLEN具体化)した文字列型%String
- 日時型
%DateTime
特にその中でも実質的に「列挙体」に当たる存在との折り合いの付け方
そして、このうち「最大長を観念した文字列型」として、
プロジェクト内では特定の意味合いを持ったいくつかの文字列のうちいずれかの値を取る
という性質のものが頻繁に登場します。 説明上の架空の一例として、下記を挙げます。
- ホテルの部屋のベッドの配置区分
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=GOBJ_proplit
- オブジェクトクラスに定義できる**「リテラル値を保持し、データ型クラス(後述)に基づいているプロパティ」**の総称
プロパティメソッド
https://docs.intersystems.com/iris20251/csp/docbookj/DocBook.UI.Page.cls?KEY=GOBJ_propmethods_def
- オブジェクトクラスが持つ各プロパティに関連して(多くの場合自動的に)生成されるメソッド群
- その生成の源泉は大きく2つあり、開発者がその在り方を定義できるのは、後者のみ
- プロパティ動作クラス(開発者レベルで再定義不可)
- プロパティの型として指定されたデータ型クラス(開発者レベルで定義可)
メソッドジェネレータ
https://docs.intersystems.com/iris20251/csp/docbookj/DocBook.UI.Page.cls?KEY=GOBJ_generators
- コンパイル時点で実行されるコードにより、あるメソッドの実装(ボディ部)を動的に定義する仕組み
- あくまで動的に生成可能なのはボディ部であり、シグニチャレベル(メソッド名やその引数、返値の型)の動的な定義は行えない
- 生成結果を空の実装とすることで、メソッド自体をサブクラス(ないしデータ型クラスを取り込んだオブジェクトクラスの実装)から削除することができる
データ型クラス
https://docs.intersystems.com/iris20251/csp/docbookj/DocBook.UI.Page.cls?KEY=GOBJ_datatypes
ClassType属性にdatatypeを指定されたクラス- 基本的に
%DataTypeのサブクラスとなる
- 基本的に
重要点1: リテラルプロパティの個々の定義レベルでのパラメータのオーバーライド可能性
データ型クラスのパラメータは、特定のプロパティに対してオーバーライド可能です。 以下に、あるデータ型クラスとそれをリテラルプロパティの型とするオブジェクトクラスのミニマムな例を示します。
Class DataType.PatientId Extends %String
{
Parameter MAXLEN [ Final ] = 10;
}
Class Demo.PatientIdKeeper Extends %RegisteredObject
{
Property PatientId As DataType.PatientId(MAXLEN = 12);
}
この定義下で下掲のような挙動を取ります。
- データ型クラス自体としては、11文字の値を最大長違反の無効値としている
- パラメータをオーバーライドしたリテラルプロパティとしては11文字の値は有効値としている

- 実質的には、
DataType.PatientIdをベースに、新規の型をリテラルプロパティの定義ごとに再定義しているような実態 - これはオブジェクトクラスにおいて、パラメータの定義が、そのクラス自体を継承することでしか変更できない点と対照的である
重要点2: 自身をデータ型とするリテラルプロパティの動作定義のためのメソッドセットの少し特殊な挙動
データ型クラスには、コンパイラがリテラルプロパティの動作を定義するために使用する、特定のメソッドのセット (通常はメソッド・ジェネレータ) を定義します。 以下に、あるデータ型クラスとそれをリテラルプロパティの型とするオブジェクトクラスの小規模な例を示します。
Class DataType.Hoge Extends %String
{
ClassMethod GetFqcn() As %String [ CodeMode = objectgenerator ]
{
Do %code.WriteLine(" Return """_%class.Name_"""")
}
ClassMethod IsValid(val As %RawString) As %Status
{
Return $ListFind(..GetValues(), val) > 0
}
ClassMethod GetValues() As %List
{
Return $ListBuild("H", "O", "G", "E")
}
}
Class Demo.HogeKeeper Extends %RegisteredObject
{
Property Hoge As DataType.Hoge;
}
まず、特におそらく疑いが生じない点として、DataType.Hogeクラス自体のGetFqcn()の実装の内実は下記のようになります。
GetFqcn() { Return "DataType.Hoge" }
重要となったのは、「独立に観念できるデータ型クラスの実装が、それをリテラルプロパティの型として使用するオブジェクトクラスの実装から利用される」ではない点です。
実際には**「オブジェクトクラスのコンパイル時点で『データ型クラスの実装』と『データ型クラスのパラメータ値』を統合したスナップショット的実装が生み出され、次のコンパイルまでは一貫してそれが利用される」**ような内実となります。
Demo.HogeKeeperクラスのHogeプロパティに関連する実装を題材とすれば、下掲のように具体化できます。
HogeGetFqcn() { Return ##class(DataType.Hoge).GetFqcn }
HogeGetValues() { Return ##class(DataType.Hoge).GetValues(val) }
HogeIsValid(val) { Return ##class(DataType.Hoge).IsValid(val) }
(↑のようでなく) (↓のようである)
HogeGetFqcn() { Return "Demo.HogeKeeper" } // メソッドジェネレータがこのオブジェクトクラスのコンパイル時に評価される結果
HogeGetValues() { Return $ListBuild("H", "O", "G", "E") } // データ型クラス自体の実装を呼び出すのではない
HogeIsValid(val) { Return $ListFind(..HogeGetValues(), val) > 0 }
このいわば「オブジェクトクラスのコンパイル時点でのデータ型クラスのスナップショット的取り込み」の帰結として、以下のような特殊性が出ています。
- 特殊性P: データ型クラスの定義内で記した、「自クラス」の参照(
..MethodName,..#ParameterName, メソッドジェネレータ内での%class等)は、リテラルプロパティに関連しての動作時はデータ型クラスそのものでなく、そのプロパティを持つオブジェクトクラスを指すものとなる - 特殊性Q: あるデータ型クラス
Aをリテラルプロパティの型とするオブジェクトクラスXのコンパイル後にAの定義を変更してコンパイルしても、Xの動作は古いAに基づくものであり続ける
実検討
筆者が列挙体に求める性質
- ある文脈における有限個数の取りうる値群(以下、列挙子)を表現できること
- ある値が列挙子の1つと言えるかのバリデーションを行う手段を提供すること
- 各列挙子を、それを意味上自然と想起させる識別子で参照できること
- 列挙子の並びを一元的に定義でき、その定義の変更が、既存のその列挙体を参照する文脈(≒データ型として指定したプロパティ)に作用すること
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
- 上述の
StringWithStickyValidationとEnumを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の積極的な活用は必須の認識であるため、今後もそれに関連する様々な実践・工夫について探っていくことになる見込みです。
今回はライティングコンテストと言うこともあり、ある程度の分量を記しやすそうな内容を選ばせていただきましたが、他の検討内容についても、また機会を見て本コミュニティに投稿させていただき、他開発者の方からお知恵を借りられればと改めて感じます。
まとまりに欠く部分もあり、またそもそもニッチな内容であったかと思われますが、ここまでご覧いただきありがとうございました。