alx9r
alx9r

Reputation: 4263

Is there a way to create an immutable record type that supports @{} object initialization?

The following code creates a C# record type, instantiates it using the hashtable object initialization idiom, then modifies the record's field:

Add-Type -TypeDefinition @'
namespace n {
    public record r {
        public string s {get; init;}
    }
}
'@

$r = [n.r]@{s='value'}
$r
$r.s = 'new_value'
$r

The field s is, ostensibly, read-only after initialization, however, the output is as follows:

s
-
value
new_value

The corresponding C#

using System;

namespace n {
    public record r {
        public string s {get; init;}
    }
}
public class Program
{
    public static void Main()
    {
        var r = new n.r {s="value"};
        r.s = "new_value";
    }
}

indeed enforces immutability at compile time with the error

Compilation error (line 13, col 3): Init-only property or indexer 'r.s' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.

I am looking for a way to create a type with the following features:

Can such a type be created?

Types that don't work

The code below attempts to accomplish this with each way I could think of to create such an immutable record type in C#. (I'm no C# expert, so I expect I probably missed some.) For reference it also attempts the same with New-Object -Property. It outputs the following:

OK cl init_error                     init_origi init_mod_error                 init_after new_error                      new_origin new_mod_error                  new_after_
   as                                nal                                       _mod                                      al                                        mod
   s
-- -- ----------                     ---------- --------------                 ---------- ---------                      ---------- -------------                  ----------
X  r1                                value                                     new_value                                 value                                     new_value
X  r2                                value                                     new_value                                 value                                     new_value
X  r3                                value                                     new_value                                 value                                     new_value
X  r4                                value                                     new_value                                 value                                     new_value
X  r5 Cannot create object of type …            The property 's' cannot be fo…            The value supplied is not val…            The property 's' cannot be fo…
X  r6 Cannot convert the "System.Co…            The property 's' cannot be fo…            A constructor was not found. …            The property 's' cannot be fo…
X  r7 Cannot convert the "System.Co…            The property 's' cannot be fo…            A constructor was not found. …            The property 's' cannot be fo…

These methods either don't support hashtable object initialization or do allow the object property to be modified after creation.

Add-Type -TypeDefinition @'
namespace n {
    public       record  r1 { public string s {get; init;} }
    public record class  r2 { public string s {get; init;} }
    public record struct r3 { public string s {get; init;} }
    public class         r4 { public string s {get; init;} }
    public class         r5 { public string s {get;} }
    public class  r6 {
        public readonly string s;
        public r6 (string s) { this.s = s; }
    }
    public class  r7 {
        public string s {get;}
        public r7 (string s) { this.s = s; }
    }
}
'@

$(foreach ($n in 'r1','r2','r3','r4','r5','r6','r7') {
    $r = $null
    [pscustomobject]@{
        class          = $n
        init_error     = $(try { $r = Invoke-Expression "[n.$n]@{s='value'}"}
                           catch { $_ })
        init_original  = $r.s
        init_mod_error = $(try { $r.s = 'new_value'}
                          catch { $_ }              )
        init_after_mod = $r.s
        new_error      = $(try { $r = New-Object "n.$n" -Property @{s='value'}}
                           catch {$_})
        new_original   = $r.s
        new_mod_error  = $(try { $r.s = 'new_value'}
                             catch { $_ }              )
        new_after_mod  = $r.s
    }
}) |
    Select-Object `
        -Property @{name='OK';
                    expression = {if ('value' -in $_.new_after_mod,
                                                  $_.init_after_mod    ) {'✓'}

                                  else                                   {'X'}}},
                  * |
    Format-Table `
        -Property @{e='OK'             ; width = 2},
                  @{e='class'          ; width = 2},
                  @{e='init_error'     ; width = 30},
                  @{e='init_original'  ; width = 10},
                  @{e='init_mod_error' ; width = 30},
                  @{e='init_after_mod' ; width = 10},
                  @{e='new_error'      ; width = 30},
                  @{e='new_original'   ; width = 10},
                  @{e='new_mod_error'  ; width = 30},
                  @{e='new_after_mod'  ; width = 10}

Upvotes: 1

Views: 88

Answers (1)

alx9r
alx9r

Reputation: 4263

PowerShell invokes a constructor with argument System.Collections.Hashtable when that idiom is used. Specifying such a constructor with read-only properties seems to achieve terse named initialization of read-only properties of a plain old C# object.

The code

Add-Type -TypeDefinition @'
namespace n {
    public class r {
        public string s {get;}
        public r(System.Collections.Hashtable h) {
            if (h.ContainsKey("s") &&
                (null != (h["s"]))) {
                this.s = h["s"] as string;
            }
        }
    }
}
'@


$r = [n.r]@{s='value'}
$r
$r.s = 'new_value'
$r

outputs

s
-
value
InvalidOperation:
Line |
  18 |  $r.s = 'new_value'
     |  ~~~~~~~~~~~~~~~~~~
     | 's' is a ReadOnly property.
value

PSObject Constructor

A more complete such class probably ought to handle other common PowerShell idioms reliably. A constructor that converts from System.Management.Automation.PSObject shown here seems to handle some more of those most common idioms:

        public r(System.Management.Automation.PSObject p) {
            if (p.Properties["s"]?.Value is System.Management.Automation.PSObject) {
                var b = (p.Properties["s"].Value as System.Management.Automation.PSObject).BaseObject;
                this.s = b as string;
                return;
            }
            this.s = p.Properties["s"]?.Value as string;
        }

The statements

[n.r]$m =
    (Get-Date) |
    Select-Object -Property @{name='s';expression = {"{0:MMMM}" -f $_}}
$m

for example output

s
-
November

Upvotes: 0

Related Questions