⚙️

コンストラクター書く? 書かない?

2025/01/22に公開

さて前回の ToString() の困った話に続いて今回も誰得な PowerShell でクラスを使う話です。マニュアルに書いてある話ではあるので、ちゃんと読んでいれば済む話ではあるのですが、要注意なこともあるので書いておきます。

超簡単な結論

  1. C/C++の構造体の代わりにクラス使う → コンストラクターなしでOK。むしろ書くな。
  2. 初期化とかバリデーションとかしたいんだけど → よし書け。おすすめアリ。
  3. 参照とかあって構造が複雑、かつデータの Save/Load とかしちゃう → それ要注意な。

この話は New(....) とごちゃごちゃ書くんじゃなくてキャストでインスタンス生成する、という大前提があります。

なぜクラスか?

PowerShellでちょっと複雑なことをやろうとすると Cmdlet とパイプラインだけでは苦しいので、関数作ってデータ渡してってなります。

そんな内容ならほかの言語でやれよって? いやごもっともなんですが、それでも PowerShell でやろうというのがこのアカウントですので C/C++の構造体がほしくなるわけです(なるよね?)

PowerShell には構造体がないので、カスタムオブジェクトかハッシュテーブルか、クラスか、ってことになるわけですが、カスタムオブジェクトもハッシュテーブルもお手軽な反面、構造体としての定義がなくなるデメリットも。またどちらも <変数>.<メンバー名 | キー名> で要素にアクセスできるので、最悪まぜこぜになってしまっても何とかなる反面、要素があるかどうかを確認しようと思うと Contains() はオブジェクトでエラーになり、Get-Member はハッシュテーブルに無力と、手軽が故のデメリットも見過ごせなくなります。

なるべく手軽に、でも型チェックとか使えるものは使いたい、となるとクラスなんすよ。

コンストラクターは必要?

発端はちょっとした構造体が欲しいということなので、コンストラクターも手抜きして書きたくないのですが、データに配列やハッシュテーブルなどの取り扱い注意なものがある場合には念のため初期化もしたいわけです。不用意に <変数>.<プロパティ名>.Contains() などとすればエラーで止まるんで。

ということで Export/Import-CliXML の話でも出てきたコンストラクターですよ。

class TestClass {
    [int] $a;
    [string] $b;
    [hashtable] $h;

    #
    # これは実はよくない。理由と改善版は後程
    #
    TestClass([PSCustomObject]$o) {
        write-host "パラメータ型: $($o.GetType().Name)"
        $oProp = $o| Get-Member -MemberType NoteProperty |%{$_.Name}
        $this.GetType().GetProperties().Name |? { $_ -in $oProp; } | %{ $this.$_ = $o.$_; }
    }
}

テストのために、コンストラクターに入ってくるパラメータの型を表示しています。初期化だけならコンストラクター要らないのですが、他の処理を書く前提で初期化部分だけ入れてます。

コメントにあるようにこのコードは良くない。テストしましょう。

code> using module .\testclass.psm1
code> [TestClass]::New([PSCustomObject]@{a=1; b=2})
パラメータ型: PSCustomObject

a b h
- - -
1 2

code> [TestClass]::New(@{a=1; b=2})
パラメータ型: Hashtable

a b h
- - -
0

PowerShellの型変換は強力ですが、ハッシュテーブルのまま渡してしまうとそのまま処理され、Get-Member が空振りするので意図通りに初期化されません。PSCustomObject で受け取ると宣言しても無駄なんすよ。

なんとかならんのかとあれこれやっていると、キャストするだけでインスタンス生成できることを発見!

code> $v=[TestClass2]@{a=1; b=2}
code> $v

a b h
- - -
1 2

code> $v.GetType().Name
TestClass2

まじかぁ。いいじゃない。

デメリット

  1. コンストラクターがあると動かない
[TestClass]@{a=1; b=2}
InvalidArgument: Cannot convert the "System.Collections.Hashtable" value of type "System.Collections.Hashtable" to type "TestClass".

前のテストで動いているのはコンストラクターのない TestClass2 なのですよ。コンストラクターのサンプルで実はよくないというのはこれが理由です。正確にはコンストラクターがあるからエラーになるわけではないのですがそのあたりは後で。

  1. キャストする場合は余分なパラメータがあってはいけない
code> [TestClass2]@{a=1; b=2; undefinedparam=1}
InvalidArgument: Cannot create object of type "TestClass2". The property 'undefinedparam' was not found for the 'TestClass2' object. The settable properties are: [a <System.Int32>], [b <System.String>], [h <System.Collections.Hashtable>].

まぁそういうものですけど。でも、もうちょっとよろしくやって欲しかったり。

使い道

もともとの構造体代わりにということであればコンストラクターなしでキャストで代入するというのはイケるでしょう。

一方で未定義プロパティを渡すとアウトというのは考えどころ。なので、データ構造への信頼性が高い場合、例えば関数への引数渡しなどでは有効なのかと。

そこまでデータの構造に信用が置けない場合や、外部からもらうデータとか、大きなオブジェクトの一部だけ取っておきたいとか、そういった場合にはコンストラクターで必要分だけ取ってくるほうが良さそう、というかそうするしかない。

マニュアル読め、という話

型変換でインスタンス化できるってすげーと思ってふとマニュアルを見ると、サンプルでやってるわけですよ!

https://learn.microsoft.com/ja-jp/powershell/module/microsoft.powershell.core/about/about_classes_properties?view=powershell-7.4#example-2---class-properties-with-custom-types

class ExampleProject2 {
    [string]          $Name
    [int]             $Size
    [ProjectState]    $State
    [ProjectAssignee] $Assignee
    [datetime]        $StartDate
    [datetime]        $EndDate
    [datetime]        $DueDate
}

[ExampleProject2]@{
    Name     = 'Class Property Documentation'
    Size     = 8
    State    = 'InProgress'
    Assignee = @{
        DisplayName = 'Mikey Lombardi'
        UserName    = 'michaeltlombardi'
    }
    StartDate = '2023-10-23'
    DueDate   = '2023-10-27'
}

最後のところを変数に代入するように変えて実行すると

$v = [ExampleProject2]@{....}

code> $v.GetType()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     False    ExampleProject2                          System.Object

ですよね~。

code> $v.Assignee.GetType()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     False    ProjectAssignee                          System.Object

おう。プロパティ(いわゆるインスタンス変数)もちゃんと型変換されてクラスインスタンス化されてます。便利すぎる…。サンプルコードも見てたけど、インスタンス生成のところまでは注目してなかったっすわ。

コンストラクター改良版

いつコンストラクター書くか

もう全部キャストで良いんじゃない? という話はあるのですが、実際としても初期化するだけのコンストラクターはもう書かないと思いますが、データのバリデーションとかしたくなる場合もあります。

$c = [MyClass]@{ 初期化データ }
if (!$c.isOK()) { エラー処理 }

みたいな。で、コンストラクター書くとキャストできなくなるわけです。

[TestClass]@{a=1; b=2}
InvalidArgument: Cannot convert the "System.Collections.Hashtable" value of type "System.Collections.Hashtable" to type "TestClass".

クラスを利用する側からしたら、コンストラクターが書いてあるかどうかなんて知ったこっちゃないですから、これでは困ります。

ということで試行錯誤した改良版のコンストラクターがこれ。キャストするときにも実はコンストラクターが呼ばれるってことで小細工します。コンストラクター書くとキャストでエラーになるというのは、正しくはキャストに対応するコンストラクターではなかったってことですね(わかるかー!)。

改良版コンストラクター

    TestClass($o) {
        $oProp = $o -is [hashtable] ? $o.Keys : ($o| Get-Member -MemberType NoteProperty,Property |%{ $_.Name })
        $this.GetType().GetProperties().Name |? { $_ -in $oProp } |%{ $this.$_ = $o.$_ }
    }

ポイントは、

  1. 引数の型指定しない。してもハッシュテーブルはそのまま来るので動作には関係ないのですが、[PSCustomObject]$o と書いておくとハッシュテーブルも来るのを忘れるので良くない。
  2. $o のメンバー(あるいはプロパティ、あるいはキー)の名前を知りたいのですが、ハッシュテーブルとカスタムオブジェクトで方法が違うので分岐して取っておく。
  3. GetType().GetProperties() はカスタムオブジェクト相手には動かないので、長くなっても Get-Member でやる
  4. $o がカスタムオブジェクトなら NoteProperty、インスタンスなら Property になるので Get-Member で両方取っておく。

テストしましょう。

code> class TestClass {
>>     [int] $a;
>>     [string] $b;
>>     [hashtable] $h;
>>
>>     TestClass($o) {
>>         write-host "パラメータ型: $($o.GetType().Name)"
>>         $oProp = $o -is [hashtable] ? $o.Keys : ($o| Get-Member -MemberType NoteProperty,Property |%{ $_.Name })
>>         $this.GetType().GetProperties().Name |? { $_ -in $oProp; } | %{ $this.$_ = $o.$_; }
>>     }
>> }
code> [TestClass]@{a=1; b=2; undefparam=1} # 余分なプロパティがあってもOK
パラメータ型: Hashtable

a b h
- - -
1 2

code> [TestClass][PSCustomObject]@{a=1; b=2; undefparam=1} # オブジェクトでもOK
パラメータ型: PSCustomObject

a b h
- - -
1 2

code> $v = [TestClass][PSCustomObject]@{a=1; b=2; undefparam=1}
パラメータ型: PSCustomObject
code> $v

a b h
- - -
1 2

code> $v2 = [TestClass]$v # これは要注意。インスタンス生成ではなく参照のコピーなので、元データが変更される
code> $v2.a = 100
code> $v

  a b h
  - - -
100 2

code> $v3 = [TestClass]::New($v) # これは正しくインスタンス生成
パラメータ型: TestClass
code> $v3.a=1000
code> $v

  a b h
  - - -
100 2

[TestClass]$v の用法で $v がインスタンスならデータ参照を作成、ハッシュテーブルなどであれば新規インスタンス作成と挙動が変わるのは気持ち悪いですが、単純にデータ参照を別変数にコピーする場合はキャストしないのが普通だと思うので、実際にはないってことでいいですね?

よし、いいじゃない。もう New() は要らんな(暴論)。

Import-CliXML の際にはご注意

前回の記事でも話題にした Import-Clixml ですがこれと組み合わせる場合にはやはり要注意です。

class Owner {
    [string]$Name;
    [int]$RefCount;
}

class ACL {
    [string]$Path;
    [Owner]$Owner;
}

class DataSet {
    [Owner[]] $Owners;
    [ACL[]] $ACLs;
}

という前回も使ったこの構造のデータを Export/Import-CLixml して変数に入れたとき、変数.ACLs[].Owners の各要素は 変数.Owners への参照として復元されます。

code> $d = import-Clixml .\t.xml
code> $d

Name                           Value
----                           -----
Owners                         {Owner, Owner, Owner}
ACLs                           {ACL, ACL, ACL, ACL…}

code> $d.Owners

Name                        RefCount
----                        --------
NT SERVICE\TrustedInstaller      346
NT AUTHORITY\SYSTEM              151
BUILTIN\Administrators           204

code> $d.ACLs[0].Owner.RefCount += 1000
code> $d.Owners

Name                        RefCount
----                        --------
NT SERVICE\TrustedInstaller     1346 <-参照されているので更新される
NT AUTHORITY\SYSTEM              151
BUILTIN\Administrators           204

ただしこの時の Owners も ACLs も PSObject の ArrrayList です。これはあまりよろしくない。

code> $d.acls[0].GetType().Name
PSObject
code> $d.Owners[0].GetType().Name
PSObject

で Import の際にキャストします。

code> $dd = [DataSet](Import-Clixml .\t.xml)
code> $dd.GetType()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     False    DataSet                                  System.Object

code> $dd.ACLs.GetType()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     ACL[]                                    System.Array

code> $dd.Owners.GetType()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Owner[]                                  System.Array

ここまではいい。すばらしい。のですが、参照じゃなくなってるんですねー。

code> $dd.ACLs[0].Owner.RefCount += 1000
code> $dd.ACLs[0].Owner.RefCount
1346
code> $dd.Owners

Name                        RefCount
----                        --------
NT SERVICE\TrustedInstaller      346 <- 変わってない
NT AUTHORITY\SYSTEM              151
BUILTIN\Administrators           204

まぁ内部的に ACL.Owner が初期されるときに都度インスタンス生成されているだろうということは簡単に想像できますからわかるんですが。おしいよー。もうちょっとがんばろうよ。

ということで複雑な構造のデータは自動的な処理に頼りっぱなしになるな、という当たり前の教訓です。

Discussion