Add-Type で C# のクラスをその場でコンパイルし、PowerShell から呼べるDllImport で Win32 API を直接呼べるcsc.exe で exe にする選択肢も持てるPowerShell は1行ごとに解釈しながら実行する言語です。1回の処理が重い分には差が出ませんが、同じ処理を何十万回も回すと、その解釈のコストがそのまま積み上がります。
1 から 300 万までの合計を求めるだけの処理を、3通りで測りました。
$n = 3000000
Add-Type -TypeDefinition @"
public static class Bench
{
public static long SumTo(int n)
{
long s = 0;
for (int i = 1; i <= n; i++) { s += i; }
return s;
}
}
"@
$t1 = Measure-Command { $sum = 0L; for ($i = 1; $i -le $n; $i++) { $sum += $i } }
$t2 = Measure-Command { $null = (1..$n | Measure-Object -Sum).Sum }
$t3 = Measure-Command { $null = [Bench]::SumTo($n) }
| 書き方 | 5.1 | pwsh 7 | 備考 |
|---|---|---|---|
PowerShell の for | 9,524 ms | 3,204 ms | 素直に書いた場合 |
パイプライン Measure-Object | 9,258 ms | 3,902 ms | 速くはならない |
Add-Type の C# | 6 ms | 13 ms | 1000 倍超 |
差は 250 倍から 1700 倍です。同じアルゴリズムでこれだけ開くのは、言語の実行方式が違うためで、書き方の工夫では埋まりません。
速度とは別に、PowerShell の文法では表現できないものがあります。代表が値型(struct)と、それを要求する Win32 API です。
Add-Type -TypeDefinition @"
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential)]
public struct RECT { public int Left, Top, Right, Bottom; }
"@
class PsPoint { [int]$X; [int]$Y } # PowerShell の class
[RECT].IsValueType # → True
[PsPoint].IsValueType # → False
[System.Runtime.InteropServices.Marshal]::SizeOf([type]'RECT') # → 16
PowerShell の class は必ず参照型になります。メモリ上の並びを固定した構造体は作れないため、RECT のような構造体を受け渡す API は C# を経由するしかありません。
Add-Type は最初の選択肢ではなく、④ を試したあとの選択肢$n = 20000
$t1 = Measure-Command { $s = ''; for ($i = 0; $i -lt $n; $i++) { $s += "$i," } }
$t2 = Measure-Command {
$sb = [System.Text.StringBuilder]::new()
for ($i = 0; $i -lt $n; $i++) { [void]$sb.Append($i).Append(',') }
$null = $sb.ToString()
}
$t3 = Measure-Command { $null = [StrBench]::Build($n) } # C# 側で StringBuilder を回す
| 書き方 | 5.1 | pwsh 7 |
|---|---|---|
PowerShell で $s += "..." | 1,525 ms | 1,514 ms |
PowerShell から StringBuilder | 56 ms | 200 ms |
Add-Type の C# | 16 ms | 4 ms |
StringBuilder の行だけ、5.1 のほうが 7 より速くなっています。for ループ自体の速さ(7 が約3倍速い)と打ち消し合った結果です。Get-ChildItem や Where-Object のような1回あたりが重い処理では差は出ません。Add-Type -TypeDefinition の基本Add-Type -TypeDefinition に C# のソースコードを文字列で渡すと、その場でコンパイルされ、そのセッションで型が使えるようになります。プロジェクトファイルもビルド手順もありません。
Add-Type -TypeDefinition @"
public class Temperature
{
public double Celsius { get; private set; }
public Temperature(double celsius) { Celsius = celsius; }
public double ToFahrenheit() { return Celsius * 9.0 / 5.0 + 32.0; }
public static Temperature FromFahrenheit(double f) { return new Temperature((f - 32.0) * 5.0 / 9.0); }
public override string ToString() { return Celsius.ToString("0.0") + " degC"; }
}
"@
ソースは @" 〜 "@(ヒアストリング)で囲みます。C# の中に $ が現れると PowerShell が変数展開してしまうため、変数を埋め込まないなら @' 〜 '@(シングルクォート版)のほうが安全です。
$t = [Temperature]::new(25) # コンストラクタ(推奨)
$t.ToFahrenheit() # → 77
$t.ToString() # → 25.0 degC
$t2 = New-Object Temperature 100 # New-Object でも同じ
$t2.ToFahrenheit() # → 212
[Temperature]::FromFahrenheit(451) # 静的メソッド → 232.8 degC
C# 側で書いた public メンバは、そのまま PowerShell のメンバとして見えます。
PS> $t | Get-Member -MemberType Method, Property | Select-Object Name, MemberType
Name MemberType
---- ----------
Equals Method
GetHashCode Method
GetType Method
ToFahrenheit Method
ToString Method
Celsius Property
-PassThru で型情報を受け取るAdd-Type は既定では何も出力しません。-PassThru を付けると、作られた型(System.Type)が返ります。
PS> Add-Type -TypeDefinition $src -PassThru | Format-Table Name, IsPublic, BaseType -AutoSize
Name IsPublic BaseType
---- -------- --------
Temperature True System.Object
ディスクには何も残りません。メモリ上のアセンブリとして、実行中のプロセスにだけ読み込まれます。
PS> Add-Type -TypeDefinition 'public class Loc { }'
PS> "[" + [Loc].Assembly.Location + "]"
[]
PS> [Loc].Assembly.FullName.Split(',')[0]
xi04rcin
Location は空文字列、アセンブリ名は実行のたびに変わるランダムな文字列です。この「ファイルとして残らない」性質が、次章以降の制約(型を作り直せない・毎回コンパイル時間がかかる)の原因になります。
public なメソッドもプロパティも無いクラスを定義すると警告が出ます。WARNING: The generated type defines no public methods or properties.Loc のような空クラスがこれにあたります。pwsh 7では同じコードでも警告は出ません。
[Temperature]::new(25) の :: は静的メンバへのアクセスで、C# の . にあたります。コンストラクタも new という名前の静的メソッドとして扱われるため、この書き方になります。$t.ToFahrenheit() のように . です。:: が静的、. がインスタンスと覚えてください。
csc.exe か、プロセス内の Roslyn かこの違いは、エラーメッセージの形にそのまま現れます。同じ間違った C# を両方に食わせた結果です。
# 5.1 — ファイルパスが出る。CS 番号が無い
C:\Users\<username>\AppData\Local\Temp\wp4uszhu.0.cs(1) : 'int' は無効です。
# pwsh 7 — 行と列。CS 番号が付く
(1,76): error CS0234: 型または名前空間の名前 'Forms' が名前空間 'System.Windows' に存在しません (アセンブリ参照があることを確認してください)
5.1 が呼ぶ csc.exe は C# 5 までしか受け付けません。同じソースを両方でコンパイルした結果です。
| C# の機能 | 例 | 5.1 | pwsh 7 |
|---|---|---|---|
var(C# 3) | var x = 1; | OK | OK |
async/await(C# 5) | await Task.Delay(1); | OK | OK |
| 式形式メンバ(C# 6) | public static string N => "a"; | NG | OK |
nameof(C# 6) | nameof(L2) | NG | OK |
| 文字列補間(C# 6) | $"value={n}" | NG | OK |
out var(C# 7) | int.TryParse(s, out int v) | NG | OK |
record・init(C# 9) | public record M2(int X); | NG | OK |
| ファイルスコープ名前空間(C# 10) | namespace Demo; | NG | OK |
| 生文字列リテラル(C# 11) | """abc""" | NG | OK |
5.1 で C# 6 の文字列補間を使ったときの実際のエラーです。$ が理解できていません。
C:\Users\<username>\AppData\Local\Temp\tjem3wc3.0.cs(4) : 文字 '$' は予期されていません。
var と async までを使い、文字列補間・式形式メンバ・out var・nameof は使わない。文字列の組み立ては "a" + b か string.Format() にします。-Language と -CompilerOptions5.1 は C# 以外の言語も受け付けます。7 は C# のみです。
PS> $t = (Get-Command Add-Type).Parameters['Language'].ParameterType
PS> [Enum]::GetNames($t) -join ', '
# 5.1 → CSharp, CSharpVersion3, CSharpVersion2, VisualBasic, JScript
# 7 → CSharp
実際に 5.1 で Visual Basic のクラスを定義すると通ります。7 ではパラメータの束縛の時点で失敗します。
# pwsh 7
Add-Type: Cannot bind parameter 'Language'. Cannot convert value "VisualBasic" to type
"Microsoft.PowerShell.Commands.Language". Error: "Unable to match the identifier name
VisualBasic to a valid enumerator name. ...
コンパイラに追加のオプションを渡すパラメータも、名前ごと入れ替わっています。
| 目的 | 5.1 | pwsh 7 |
|---|---|---|
| コンパイラへの追加指定 | -CompilerParameters | -CompilerOptions |
| 渡すもの | CompilerParameters オブジェクト(CodeDom) | csc のスイッチを文字列で |
-ReferencedAssemblies の意味が逆になるここが最も混乱する箇所です。同じコードで、5.1 は参照を足さないと通らず、7 は参照を足すと通らなくなります。
$src = 'using System.Xml.Linq;
public class R { public static string V() { return XDocument.Parse("<a/>").Root.Name.LocalName; } }'
| 指定 | 5.1 | pwsh 7 |
|---|---|---|
| 指定なし | NGSystem に Xml が無い | OK |
-ReferencedAssemblies System.Xml.Linq | NGSystem.Xml も要る | NGCS0103 |
-ReferencedAssemblies System.Xml.Linq, System.Xml | OK | — |
-ReferencedAssemblies <dll のフルパス> | — | OK |
-ReferencedAssemblies System.Windows.Forms(WinForms を使う場合) | OK | OK |
7 の挙動は「参照が足りない」のではなく、-ReferencedAssemblies を指定すると既定の参照集合が置き換わるためです。無関係なアセンブリを1つ指定しただけで、それまで見えていた System.Xml が見えなくなります。
# pwsh 7 — System.Windows.Forms を指定しただけで System.Xml が消える
Add-Type -TypeDefinition $src -ReferencedAssemblies 'System.Windows.Forms'
(1,14): error CS0234: 型または名前空間の名前 'Xml' が名前空間 'System' に存在しません
-ReferencedAssemblies を付けずに試してください。[型].Assembly.Location で取ったフルパスを渡します。System.Xml.Linq のようなアセンブリ名を渡すと、実体のない転送専用の dll を掴んで CS0103 になります。
同じセッションで、内容の違うクラスを3回続けて定義したときの所要時間です。
| 回数 | 5.1 | pwsh 7 |
|---|---|---|
| 1 回目 | 182 ms | 686 ms |
| 2 回目 | 132 ms | 23 ms |
| 3 回目 | 125 ms | 19 ms |
5.1 は毎回 csc.exe というプロセスを起動するため、何回目でも同じくらいかかります。7 は初回に Roslyn を読み込むぶん重いものの、2回目以降は 20 ms 程度まで落ちます。
Add-Type で作った型は、実行中のプロセスに読み込まれたまま外れません。同じ名前で内容の違う型を定義しようとすると、エラーになります。
Add-Type -TypeDefinition 'public class Point2 { public int X; }'
Add-Type -TypeDefinition 'public class Point2 { public int X; public int Y; }'
Add-Type : Cannot add type. The type name 'Point2' already exists.
At C:\work\t20-raw.ps1:2 char:1
+ Add-Type -TypeDefinition 'public class Point2 { public int X; public ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (Point2:String) [Add-Type], Exception
+ FullyQualifiedErrorId : TYPE_ALREADY_EXISTS,Microsoft.PowerShell.Commands.AddTypeCommand
Add-Type: C:\work\t20-raw.ps1:2
Line |
2 | Add-Type -TypeDefinition 'public class Point2 { public int X; public …
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| Cannot add type. The type name 'Point2' already exists.
Add-Type: C:\work\t20-raw.ps1:2
Line |
2 | Add-Type -TypeDefinition 'public class Point2 { public int X; public …
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| Cannot add type. Compilation errors occurred.
try / catch で捕まえたときの中身も違います。エラー ID で分岐を書いている場合は要注意です。
| 5.1 | pwsh 7 | |
|---|---|---|
| 捕まる例外の型 | System.Exception | System.InvalidOperationException |
Exception.Message | Cannot add type. The type name 'Dup' already exists. | Cannot add type. Compilation errors occurred. |
FullyQualifiedErrorId | TYPE_ALREADY_EXISTS | COMPILER_ERRORS |
エラーの後に [Dup]::V() を呼ぶと、1回目の実装がそのまま返ります。「エラーは出たが古いほうが生きている」という、いちばん気づきにくい状態です。
Add-Type は渡されたソース文字列そのものを覚えていて、完全に一致していれば2回目は何もせずに成功します。
$src = 'public class Same { public static int V() { return 1; } }'
Add-Type -TypeDefinition $src # 1回目 → OK
Add-Type -TypeDefinition $src # 2回目 → OK(何も起きない)
ところが末尾に空白を1つ足しただけで、別のソースとみなされてエラーになります。
Add-Type -TypeDefinition 'public class WS { public static int V() { return 1; } }'
Add-Type -TypeDefinition 'public class WS { public static int V() { return 1; } } '
# → Cannot add type. The type name 'WS' already exists. (5.1・7 とも同じ)
Add-Type が、2回目の実行で急にエラーを出すことがあります。Remove-Type のようなコマンドは存在しません。
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File .\dev.ps1
いちばん確実です。プロセスが終われば型も消えるので、何度でも作り直せます。C# を書き換えながら試す間は、対話セッションで Add-Type を打たないのが結局いちばん速く進みます。
同じセッションで差し替えたいなら、型名にランダムな文字列を付け、-PassThru で返る型オブジェクトを保持して呼びます。
function New-Calc([int]$factor) {
$name = 'Calc_' + [guid]::NewGuid().ToString('N')
$src = "public static class $name { public static int Mul(int x) { return x * $factor; } }"
Add-Type -TypeDefinition $src -PassThru
}
$v1 = New-Calc 2
$v1::Mul(10) # → 20
$v2 = New-Calc 3
$v2::Mul(10) # → 30 エラーにならない
[型名]:: という角括弧の記法は使えなくなり、$v1::Mul() のように変数から呼ぶ形になります。型はセッション内に溜まり続けますが、開発中なら問題になりません。
-MemberDefinition は「クラスの中身だけ」を渡すWin32 API を呼ぶだけなら、クラス全体を書く必要はありません。-MemberDefinition にメンバの宣言だけを渡すと、Add-Type がクラスの外枠を組み立ててくれます。
$sig = @"
[DllImport("kernel32.dll")]
public static extern ulong GetTickCount64();
[DllImport("user32.dll")]
public static extern int GetSystemMetrics(int nIndex);
"@
Add-Type -MemberDefinition $sig -Name 'Native' -Namespace 'Demo'
-Name がクラス名、-Namespace が名前空間になり、[Demo.Native] という型ができます。
PS> [Demo.Native]::GetTickCount64()
1214534562
PS> [Demo.Native]::GetSystemMetrics(0) # SM_CXSCREEN
1536
PS> [Demo.Native]::GetSystemMetrics(1) # SM_CYSCREEN
864
PS> [Demo.Native]::GetSystemMetrics(80) # SM_CMONITORS
1
5.1・7 のどちらでも同じ結果です。DllImport は OS の関数を直接呼ぶだけなので、ここにバージョン差はありません。
出力引数に構造体を要求する API は、struct を自分で定義して [ref] で渡します。
Add-Type -TypeDefinition @"
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential)]
public struct RECT { public int Left, Top, Right, Bottom; }
public static class Win
{
[DllImport("user32.dll")]
public static extern bool GetWindowRect(System.IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll")]
public static extern System.IntPtr GetDesktopWindow();
}
"@
$r = New-Object RECT
$ok = [Win]::GetWindowRect([Win]::GetDesktopWindow(), [ref]$r)
PS> $ok
True
PS> "Left=$($r.Left) Top=$($r.Top) Right=$($r.Right) Bottom=$($r.Bottom)"
Left=0 Top=0 Right=1536 Bottom=864
C# 側の out 引数には、PowerShell からは [ref] を付けて渡します。[StructLayout(LayoutKind.Sequential)] はフィールドを宣言順にメモリへ並べる指定で、API が期待する並びと一致させるために必要です。
GetPrivateProfileString は、文字列バッファを渡して書き込んでもらう典型的な API です。VBA から呼んだことがある方も多いはずです。
$sig = @"
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern uint GetPrivateProfileString(
string lpAppName, string lpKeyName, string lpDefault,
System.Text.StringBuilder lpReturnedString, uint nSize, string lpFileName);
"@
Add-Type -MemberDefinition $sig -Name 'Ini' -Namespace 'Win32'
function Get-IniValue([string]$Path, [string]$Section, [string]$Key, [string]$Default = '') {
$buf = New-Object System.Text.StringBuilder 1024
$len = [Win32.Ini]::GetPrivateProfileString($Section, $Key, $Default, $buf, $buf.Capacity, $Path)
$buf.ToString(0, $len)
}
読ませた INI と、その結果です。
[Database]
Server=db01.example.jp
Port=5432
[App]
Timeout=90
PS> Get-IniValue .\sample.ini 'Database' 'Server'
db01.example.jp
PS> Get-IniValue .\sample.ini 'App' 'Timeout' '30'
90
PS> Get-IniValue .\sample.ini 'App' 'NoSuchKey' '(既定値)'
(既定値)
キーが無ければ第3引数の既定値が返ります。CharSet = CharSet.Unicode を付けているので、GetPrivateProfileStringW のほうが呼ばれ、日本語の値もそのまま読めます。
[Database] だけが読めなくなります。Database/Server が既定値に落ち、App/Timeout は 90 が返りました。BOM の3バイトが最初のセクション名の直前に居座り、[Database] が別の名前として扱われるためです。-UsingNamespace 'System.Runtime.InteropServices' を付けると、逆にコンパイルが失敗します。-MemberDefinition は DllImport を使う前提なので、この using を最初から自動で入れています。重ねて指定すると CS0105(重複した using)になり、しかも警告がエラー扱いのため止まります。5.1・7 の両方で同じです。
(3,7): error CS0105: 'System.Runtime.InteropServices' の using ディレクティブは、
この名前空間で既に使用されています
DllImport は Java の JNI にあたりますが、C 側のグルーコードを書く必要がありません。宣言だけで .NET が呼び出し規約とマーシャリングを面倒みます。Add-Type で C# を用意しても、PowerShell から1回ずつ呼んでいては効果がありません。同じ合計処理を、C# の呼び方だけ変えて測りました(30 万回)。
$n = 300000
$t1 = Measure-Command { $s = 0L; for ($i = 1; $i -le $n; $i++) { $s = $s + $i } }
$t2 = Measure-Command { $s = 0L; for ($i = 1; $i -le $n; $i++) { $s = [B4]::Add($s, $i) } }
$t3 = Measure-Command { $s = 0L; for ($i = 1; $i -le $n; $i++) { $s = [PsCalc]::Add($s, $i) } }
$t4 = Measure-Command { $null = [B4]::SumTo($n) }
| 呼び方 | 5.1 | pwsh 7 | 評価 |
|---|---|---|---|
| A PowerShell だけ | 774 ms | 818 ms | 基準 |
| B 1回ずつ C# を呼ぶ | 702 ms | 1,809 ms | 効果なし7 では遅くなった |
C 1回ずつ PowerShell の class を呼ぶ | 2,143 ms | 2,670 ms | 最も遅い |
| D ループごと C# に渡す | 14 ms | 4 ms | 50〜200 倍 |
B と D の差が、この章でいちばん実務に効く数字です。C# にするのは「1回の計算」ではなく「ループそのもの」。境界をまたぐ回数が減らないなら、C# を書く意味はありません。
class は速度対策になりません。for より 2.6〜3.3 倍遅くなりました。PowerShell の class はコードの整理のための機能であって、実行方式は関数と変わりません。
11.1 で見たとおり、文字列連結は StringBuilder に変えるだけで 7〜27 倍になりました。同じことが集合演算やリスト操作にも当てはまります。
| やりたいこと | PowerShell だけで書くと | 先に試す .NET の型 |
|---|---|---|
| 文字列を大量に連結 | $s += "..." | [System.Text.StringBuilder] |
| 配列に要素を足し続ける | $a += $x | [System.Collections.Generic.List[object]] |
| 含まれるか判定 | $a -contains $x | [System.Collections.Generic.HashSet[string]] |
| 1行ずつ読む | Get-Content | [System.IO.StreamReader] |
いずれも Add-Type なしで、PowerShell からそのまま使えます。型を1つ差し替えるだけなので、C# を書き起こすのに比べて手間も保守コストも桁違いに小さくて済みます。
| 観点 | PowerShell だけ | .NET の型を使う | Add-Type で C# |
|---|---|---|---|
| 速度 | 基準 | 数倍〜数十倍 | 数十倍〜1000 倍 |
| 読める人 | チーム全員 | ほぼ全員 | C# が読める人だけ |
| 直すとき | その場で書き換え | その場で書き換え | C# を直して再実行。セッションを作り直す(11.4) |
| 5.1 と 7 の差 | 小さい | 小さい | 大きい(11.3) |
| 起動コスト | なし | なし | 毎回 20〜180 ms(11.3) |
| デバッグ | 行単位で追える | 行単位で追える | C# の中は Write-Host も効かない |
Measure-Command でどこが遅いか実測する。想像で決めないAdd-Type を使うAdd-Type でよいここまでの Add-Type は、実行するたびにメモリ上でコンパイルする方式でした。もう一つ、事前に1回だけコンパイルして .exe や .dll を作っておくという選択肢があります。
Visual Studio も dotnet SDK も要りません。.NET Framework 4.x が入っている Windows には、必ず csc.exe があります。この PC で実際に見つかったものです。
| コンパイラ | 場所 | バージョン |
|---|---|---|
| .NET Framework 同梱 | C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe | 4.8.9221.0 |
| 旧版(残存) | …\Framework64\v3.5\csc.exe | 3.5.30729.9151 |
| 旧版(残存) | …\Framework64\v2.0.50727\csc.exe | 8.0.50727.9157 |
| Visual Studio の Roslyn | …\VisualStudio\2022\Community\MSBuild\Current\Bin\Roslyn\csc.exe | 4.500.23.12814 |
| dotnet SDK | C:\Program Files\dotnet\dotnet.exe | 7.0.202 |
下の2つはインストールした人にしかありません。上の Framework64\v4.0.30319\csc.exe だけが、追加インストールなしでどの Windows にもあります。開発ツールを入れられない業務 PC でも、小さなツールを1本ビルドできる——これが実務上の価値です。
csc.exe の場所は、5.1 なら実行中のランタイムから引けます。
$dir = [System.Runtime.InteropServices.RuntimeEnvironment]::GetRuntimeDirectory()
Join-Path $dir 'csc.exe'
# 5.1 → C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe (存在する)
# 7 → C:\Program Files\PowerShell\7\csc.exe (存在しない)
7 は .NET 上で動いているため、この方法では pwsh.exe のフォルダを指してしまいます。7 から使うなら、パスを直接書くのが確実です。
Sum.cs — 1 から n までの合計を出すだけのコンソールアプリです。終了コードを返すように書いておきます(08 章)。
using System;
class Program
{
static int Main(string[] args)
{
if (args.Length != 1)
{
Console.Error.WriteLine("使い方: Sum.exe <n>");
return 1;
}
int n;
if (!int.TryParse(args[0], out n))
{
Console.Error.WriteLine("数値を指定してください: " + args[0]);
return 2;
}
long s = 0;
for (int i = 1; i <= n; i++) { s += i; }
Console.WriteLine(s);
return 0;
}
}
PS> $csc = 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe'
PS> & $csc /nologo /target:exe /out:Sum.exe Sum.cs
PS> $LASTEXITCODE
0
PS> Get-Item .\Sum.exe | Select-Object Name, Length
Name Length
---- ------
Sum.exe 4096
4 KB の exe が1つできます。動かします。カレントの実行ファイルは .\ を付けて呼びます(03 章)。
PS> .\Sum.exe 3000000
4500001500000
PS> $LASTEXITCODE
0
PS> .\Sum.exe
使い方: Sum.exe <n>
PS> $LASTEXITCODE
1
PS> .\Sum.exe abc
数値を指定してください: abc
PS> $LASTEXITCODE
2
プロセスの起動を含めて 66 ms でした。11.1 で Add-Type の C# が 6〜13 ms だったのと比べると重く見えますが、そちらにはコンパイルの 20〜180 ms が別途かかっています。1回きりの実行なら exe のほうが速く終わります。
/target:library にすると dll になります。こちらのほうが実務では使い出があります。
using System;
namespace MyTools
{
public static class Calc
{
public static long SumTo(int n)
{
long s = 0;
for (int i = 1; i <= n; i++) { s += i; }
return s;
}
public static string Reverse(string s)
{
char[] a = s.ToCharArray();
Array.Reverse(a);
return new string(a);
}
}
}
PS> & $csc /nologo /target:library /out:MyTools.dll Calc.cs
PS> $LASTEXITCODE
0
できた dll は Add-Type -Path で読み込みます。
PS> Add-Type -Path .\MyTools.dll
PS> [MyTools.Calc]::SumTo(1000)
500500
PS> [MyTools.Calc]::Reverse('PowerShell')
llehSrewoP
PS> [MyTools.Calc].Assembly.Location
C:\work\MyTools.dll
11.2 の -TypeDefinition と違い、Location にちゃんとパスが入ります。そして重要なのは次の点です。
PS> Measure-Command { Add-Type -Path .\MyTools.dll }
# 5.1 → 23 ms 7 → 5 ms どちらもエラーにならない
同じ dll を2回読み込んでもエラーになりません。11.4 の「型を再定義できない」という制約は、-TypeDefinition でソースからコンパイルする場合の話です。既に読み込み済みのアセンブリを指定した Add-Type -Path は、単に何もせず返ります。
csc.exe で作ったものですが、PowerShell 7(.NET 10)からもそのまま読めました。System.Array や System.String のような基本的なものだけだからです。WinForms や WCF のように .NET Framework 固有の機能に触れると、7 側では読み込みに失敗します。両方で使う dll は、依存を基本型に絞って書いてください。
csc.exe は例外を投げません。成否は終了コードで判定します(08.3)。わざと2箇所間違えた .cs を食わせた結果です。
PS> & $csc /nologo /target:exe /out:Bad3.exe Bad3.cs
Bad3.cs(7,17): error CS0029: 型 'string' を型 'int' に暗黙的に変換できません。
Bad3.cs(8,17): error CS0117: 'System.Console' に 'WirteLine' の定義がありません。
PS> $LASTEXITCODE
1
PS> Test-Path .\Bad3.exe
False
エラーが2つあっても終了コードは 1、そして出力ファイルは作られません。判定は -ne 0 で足ります(robocopy のような例外的な規則はありません)。
[CmdletBinding()]
param(
[string]$Source,
[string]$Output
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not $Source) { $Source = Join-Path $PSScriptRoot 'Sum.cs' }
if (-not $Output) { $Output = Join-Path $PSScriptRoot 'Sum.exe' }
$csc = 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe'
if (-not (Test-Path -LiteralPath $csc)) { throw "csc.exe が見つかりません: $csc" }
Write-Host "コンパイル: $Source"
& $csc /nologo /target:exe /optimize+ /out:$Output $Source
if ($LASTEXITCODE -ne 0) { throw "コンパイルに失敗しました (終了コード: $LASTEXITCODE)" }
Write-Host "生成: $Output ($((Get-Item $Output).Length) バイト)"
PS> .\build.ps1
コンパイル: C:\work\Sum.cs
生成: C:\work\Sum.exe (4096 バイト)
PS> .\build.ps1 -Source .\Bad3.cs -Output .\Bad3.exe
コンパイル: C:\work\Bad3.cs
Bad3.cs(7,17): error CS0029: 型 'string' を型 'int' に暗黙的に変換できません。
Bad3.cs(8,17): error CS0117: 'System.Console' に 'WirteLine' の定義がありません。
コンパイルに失敗しました (終了コード: 1)
5.1・7 のどちらで実行しても同じ結果になります。csc.exe は外部プログラムなので、呼び出し側の PowerShell のバージョンには影響されません。11.3 で見たようなコンパイラの差が消えるのも、事前コンパイルの利点です。
Framework64\v4.0.30319\csc.exe が受け付ける C# も、5.1 の Add-Type と同じ C# 5 までです。/langversion に 6 を渡すと、有効な値そのものが返ってきます。
error CS1617: /langversion に対する無効なオプション '6' です。
ISO-1、ISO-2、3、4、5、または Default でなければなりません。
5.1 の Add-Type がこの csc.exe を呼んでいるのですから、当然の一致です。C# 6 以降を使いたい場合は Visual Studio 同梱の Roslyn 版 csc.exe か dotnet SDK が要ります。
dotnet build との違いdotnet SDK が入っているなら dotnet build も使えますが、プロジェクトファイルが要ります。.cs を直接渡すことはできません。
PS> dotnet build Sum.cs
MSBuild version 17.5.0+6f08c67f3 for .NET
C:\work\Sum.cs(1,1): error MSB4025: プロジェクト ファイルを読み込めませんでした。
Data at the root level is invalid. Line 1, position 1.
PS> $LASTEXITCODE
1
csc.exe は.cs を1つ渡せば通ります。ソース1〜2ファイルの小さなツールを作るだけなら、プロジェクトを用意する手間のぶん csc.exe のほうが軽い、という関係です。NuGet パッケージを使う・複数のターゲットに向けてビルドする、といった段階になったら dotnet SDK へ移ります。
| 観点 | Add-Type | csc.exe を直接叩く |
|---|---|---|
| コンパイル時期 | 実行のたび(メモリ上) | 事前に1回 |
| 成果物 | なし(プロセス内の型) | .exe / .dll |
| 配布 | ps1 に C# ソースを同梱 | exe を配るだけ。PowerShell 不要 |
| 起動コスト | 毎回コンパイル分かかる | ゼロ |
| 型の再定義 | セッション内で不可(11.4) | 無関係 |
| 5.1 と 7 の差 | 大きい(11.3) | なし(外部プログラムのため) |
| ソースの隠蔽 | できない | C# ソースは配らなくてよい |
| 手軽さ | ps1 1本で完結 | ビルド手順が1つ増える |
.cs とビルド用の ps1 を一緒に置き、ビルドは常にそのスクリプト経由で行う——このくらいの決め事があれば十分です。Add-Type のまま ps1 に同梱しておくほうが、管理する物が1つで済みます。
| 節 | 要点 |
|---|---|
| 11.1 | 差が出るのは回数の多いループ。まず PowerShell で書き、Measure-Command で実測してから判断する |
| 11.2 | Add-Type -TypeDefinition でその場コンパイル。できた型はメモリ上のみ(Location が空) |
| 11.3 | 5.1 は csc.exe(C# 5 まで)、7 は Roslyn。-ReferencedAssemblies の要否が逆になる |
| 11.4 | 同じ型名は再定義できない。判定はソース文字列の完全一致。開発中は別プロセスで実行する |
| 11.5 | -MemberDefinition + DllImport が P/Invoke の最短形。-UsingNamespace で InteropServices を足さない |
| 11.6 | C# に渡すのは1回の計算ではなくループごと。先に .NET の型を試す |
| 11.7 | csc.exe はどの Windows にもある。事前ビルドすれば再定義の制約もバージョン差も消える |
-ReferencedAssemblies は 7 では原則付けないcsc.exe で dll にしてしまうAdd-Type と同じく「.NET の外側にある世界」を呼ぶ話ですが、解放し忘れると Excel のプロセスが残り続けるという固有の難しさがあります。