F#(fsi)を使ったExcel操作の覚え書き（準備編）

（2024.8.31 更新）
個人メモ程度の記事ですが、現在でもポツポツと反応をいただいています。
F# を取り巻く環境も変わってきたということで、現状に合わせて手を入れさせていただきました。

## はじめに
高校で非常勤講師をしております。
当然、学校でもあらゆる場面で Excel が利用されていますが、定型作業が大半です。

個人的に Excel の定型作業で F# インタラクティブ（以下 fsi）を重用しているので記事にしてみました。
fsi の詳細はこちらです。

https://learn.microsoft.com/ja-jp/dotnet/fsharp/tools/fsharp-interactive/

まずは、VBA でマクロを書いた経験のある方、そして、F# に関心のある方を対象に、fsi の導入から単独セルの読み書きまでを準備編としてまとめました。
（実例は[実例編](https://qiita.com/Mt_SQ/items/59f545a2d20fe1eaf73f)で）

::: note warn
本稿は「覚え書き」であり、個人的な試行錯誤の結果をまとめたものです。
不正確な表記や誤りもあるかと思います。
また、Office アプリケーションを外部から操作すると、「COM オブジェクトの解放漏れ」などの問題もつきまといます。
素人の実験記事として参考程度にお取り扱いください。
:::

## 作業環境
* Windows 11 Pro（Ver. 23H2）
* F# 8.0 (.NET SDK 8.0.401 または Visual Studio 2022 Community 17.11.1)
* Microsoft Excel for Microsoft 365 MSO (バージョン 2407 ビルド 16.0.17830.20166) 64bit
* PowerShell 7.4.5
* ThinkPad L15 Gen 1 (Core i5-10210U 8.00GB)

## fsi の導入と起動
fsi は Visual Studio や .NET SDK に含まれていますので、どちらかのインストールが必要です。
.NET SDK の方が簡単にインストールできますし、保守も楽です。
どちらかをインストール済みとの前提で話を進めます。
### (1) Visual Studio Community 2022 の場合
まずは、PowerShell で fsi.exe を探します。
```powershell:PowerShell 7
ls "C:\Program Files\Microsoft Visual Studio\2022\" -s fsi.exe | % f*
```
`C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\FSharp\Tools\fsi.exe`
が fsi.exe のフルパスになります。

このフルパスで起動してみます。（起動が確認できたらショートカットやバッチファイルを作ります。）
```powershell:PowerShell 7
& "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\FSharp\Tools\fsi.exe"
```

起動メッセージとプロンプトが出たら、.NET のバージョンを確認してみます。
```fsharp:F# Interactive
System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription;;
```
```fsharp:F# Interactive（結果）
val it: string = ".NET Framework 4.8.9261.0"
```
.NET Framework 4.8 環境であることがわかります。

なお、fsi ではセミコロン２つ「`;;`」が実行の指示です。
以下、「`;;`」の有無も意識してお読みください。

fsi の終了は
```fsharp:F# Interactive
#q;;  // #quit
```
です。一旦終了させておきます。

**【画面1】** fsi.exe のフルパス確認～起動 → .NET のバージョン確認 → fsi の終了
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/1a480943-6c01-b6a7-579e-a8ae6bda876e.png width=640px>

### (2) .NET 8 SDKをインストールした場合
コマンドラインから`dotnet fsi`で起動します。
こちらは .NET 8 環境になります。

**【画面2】** dotnet fsi による起動 → .NET のバージョン確認 → fsi の終了
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/3b49ccab-a977-d8b6-f0ff-dfb1cec1ceb3.png width=640px>

::: note
(1) と (2) の違いは最後に補足しておきますが、以下の作業ではどちらの環境でも問題ありません。本稿は両環境で動作確認しました。
:::

## fsi から Excel を起動する
::: note warn
初回投稿時から状況が変わり、参照設定できない場合がありました。
確実な方法として、アセンブリをフルパスで参照設定するように変更しました。
:::

### アセンブリの確認
Excel 起動前に<b>必要なアセンブリ（次の２つ）</b>を探します。
* Microsoft.Office.Interop.Excel.dll
* office.dll

```powershell:PowerShell 7
ls C:\Windows\assembly\ -s M*Excel.dll | % f*
ls C:\Windows\assembly\ -s office.dll | % f*
```
私のPC環境では
`C:\Windows\assembly\GAC_MSIL\Microsoft.Office.Interop.Excel\15.0.0.0__71e9bce111e9429c\Microsoft.Office.Interop.Excel.dll`

`C:\Windows\assembly\GAC_MSIL\office\15.0.0.0__71e9bce111e9429c\OFFICE.DLL`
が見つかりました。
（Office のバージョンやPCの環境によって異なる場合があります。）

### fsi から Excel を起動
あらためて fsi を起動します。
以下のコードを fsi にコピペするとブックのない Excel が起動します。

コード先頭の`#r`で、先ほど見つけたアセンブリへの参照を設定するので、こちらは個々の環境に合わせてください。**パス直前の`@`を忘れると`\`がエスケープ文字と解釈されて失敗します。**
```fsharp:F# Interactive
#r @"C:\Windows\assembly\GAC_MSIL\Microsoft.Office.Interop.Excel\15.0.0.0__71e9bce111e9429c\Microsoft.Office.Interop.Excel.dll"
#r @"C:\Windows\assembly\GAC_MSIL\office\15.0.0.0__71e9bce111e9429c\OFFICE.DLL"
open System.Runtime.InteropServices  // 後始末で利用
open Microsoft.Office.Interop.Excel
let ex: _Application = ApplicationClass(Visible = true)
;;
```
もし、手入力する場合は**大文字・小文字の区別がある**ことに注意してください。
また、**入力時にはTABキー補完、上下カーソルキーでの入力履歴が使えます。**

途中の`open System.Runtime.InteropServices`は後述する「後始末」で使います。

## ブックとワークシートへのアクセス
Excel の起動に成功したら、ブックを作成（または開く）し、ブックやワークシートに名前（識別子）をつけます。（let 束縛）
起動させた Excel の識別子は`ex`です。

let 束縛時、識別子には`ex: _Application`のように型を明記できます。
型を省略した場合は型推論されますが、トラブル回避のため明記した方がよいでしょう。
```fsharp:F# Interactive
let wb: _Workbook = ex.Workbooks.Add()  // 新規ブックを作成する場合
// 既存ファイルを開く場合の例
// let wb : _Workbook = ex.Workbooks.Open(@"C:\Users\user1\Desktop\sample.xlsx")
let ws: _Worksheet = downcast wb.Worksheets[1]
;;
```
コード下行の`downcast`について簡単に補足しておきます。

F# で Excel 内部のオブジェクトを取得すると<b>`obj`型を返す場合があります。
そのときは`downcast`演算子で本来の型にキャストする必要があります。</b>
キャストに関しては後半でも触れます。

## 単独セルの操作
まず、セル操作の前に Sheet1 へ適当なデータを入力します。
下表の範囲をコピーして、fsi で起動した Excel のセル A1 に **[形式を選択して貼り付け - テキスト]** でデータを入れます。
| 番号 | 姓   | 読み   |
|----:|:----:|:-------|
| 1   | 阿部 | アベ    |
| 2   | 伊藤 | イトウ  |
| 3   | 植田 | ウエダ  |
| 4   | 遠藤 | エンドウ|
| 5   | 大石 | オオイシ|

貼り付け後のイメージ
![ex1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/e9134ba4-1b18-30c7-e912-b4b39d5ab588.png)

### `Range`と`Cells`
最初に VBA でおなじみの`Range`と`Cells`について確認します。
セル B2 をそれぞれの方法で let 束縛してみますが、**あえて、型を明記しないでおきます。**

```fsharp:F# Interactive
let cell1 = ws.Range("B2")
let cell2 = ws.Cells[2, 2]
;;
```
```fsharp:F# Interactive（結果）
val cell1: Range
val cell2: obj
```
`cell1`の型は<b>`Range`</b>です。一方、`cell2`は<b>`obj`</b>です。

`cell2`の右辺 **`ws.Cells[2, 2]`は`ws.Cells.Item(2, 2)`の省略形です。**
`Cells`プロパティは全セル範囲を`Range`で返しますが、その先の **`Item`プロパティが`obj`を返します。**

::: note warn
`Cells[row, col]`だけではセルの値を取得できません。
VBA で`Value`の省略に慣れている方はご注意ください。
:::

それぞれについて`Value`プロパティで値を見てみます。
`Value`プロパティは引数をとるので、引数は省略できても( )は省略できません。`Value()`とします。
```fsharp:F# Interactive
cell1.Value();;
```
```fsharp:F# Interactive（結果）
val it: obj = "阿部"
```
`cell1`から文字列を取得できましたが、型は`obj`ということに注意してください。

::: note
let 束縛のない式を実行すると、暗黙的に<b>`it`</b>という識別子で束縛されます。
新たに書き換えられない限り`it`を利用することができます。
:::

`cell2`の`Value`プロパティを確認します。
```fsharp:F# Interactive
cell2.Value();;
```
```fsharp:F# Interactive（結果）
error FS0039: 型 'Object' は、フィールド、コンストラクター、またはメンバー 'Value' を定義していません。
```
`cell2`は`obj`なので、`Value`プロパティが使えません。
前述のように`Range`にダウンキャストする必要があります。

キャストを考慮した例です。
```fsharp:F# Interactive
let cell3: Range = downcast ws.Cells[2, 2]
let cell4: Range = ws.Cells.Resize(1, 1).Offset(1, 1)  // キャスト不要
;;
```
```fsharp:F# Interactive（結果）
val cell3: Range
val cell4: Range
```
`cell3`は`obj`から`Range`にキャストされています。

`cell4`は`Resize`, `Offset`プロパティを使ってセル B2 を指しています。
**これらのプロパティは`Range`を返してくれるのでキャストが不要です。**
単独セルに限らず、セル範囲を数値で指示したい場合に重宝します。

::: note warn
`Cells`（シート上の全セル範囲）に対して、リサイズ前にオフセットするとセル範囲がオーバーフローするので`Resize`を先にします。
:::

```fsharp:F# Interactive（エラーになります！）
let cell4 = ws.Cells.Offset(1, 1).Resize(1, 1);;  // Resize を前に置くこと！
```
### 値の取得
セルの値は`Value`や`Value2`プロパティなどで取得します。
`Value2`が簡便ですが、**日付データの取得には`Value`プロパティが適切**です。
（詳しくは[実例編](https://qiita.com/Mt_SQ/items/59f545a2d20fe1eaf73f)で）

`Value`や`Value2`は<b>`obj`を返します。</b>
```fsharp:F# Interactive
// キャストしない場合は obj を返す
let no1 = ws.Range("A2").Value()
let sei1 = ws.Range("B2").Value()
let yomi1 = ws.Range("C2").Value()
;;
```
```fsharp:F# Interactive（結果）
val no1: obj = 1.0
val sei1: obj = "阿部"
val yomi1: obj = "アベ"
```
**`obj`型は`float`（`int`は不可）や`string`にキャストしないと数値や文字列としての処理ができません。**

::: note
挿入文字列への挿入や、`Object.ToString`で文字列を作る場合などはキャスト不要です。（後述）
:::

通常はキャストしましょう。
```fsharp:F# Interactive
let no1: float = downcast ws.Range("A2").Value()
let sei1: string = downcast ws.Range("B2").Value()
let yomi1: string = downcast ws.Range("C2").Value()
;;
```
キャストには`:?>`演算子を使う方法もあります。こちらが少し簡潔です。
```fsharp:F# Interactive
// :?> 演算子を使う方法
let no1 = ws.Range("A2").Value() :?> float
let sei1 = ws.Range("B2").Value() :?> string
let yomi1 = ws.Range("C2").Value() :?> string
;;
```
どちらも結果は同じです・
```fsharp:F# Interactive（結果）
val no1: float = 1.0
val sei1: string = "阿部"
val yomi1: string = "アベ"
```
詳細は以下を参照してください。

https://learn.microsoft.com/ja-jp/dotnet/fsharp/language-reference/casting-and-conversions#downcasting

#### おまけ
`Text`プロパティは数値も文字列として返します。（要キャスト）
他に`Object.ToString`による文字列の取得、'obj'型から .NET API で型変換もできます。
```fsharp:F# Interactive
let a3_text = ws.Range("A3").Text :?> string
let a3_tostr = ws.Range("A3").Text.ToString()
let a3_int = ws.Range("A3").Value2 |> System.Convert.ToInt32
;;
```
```fsharp:F# Interactive（結果）
val a3_text: string = "2"
val a3_tostr: string = "2"
val a3_int: int = 2
```

### 値の書き込み
#### 値の用意
とりあえず、先ほど取得したデータを加工して、書き込み用に文字列を作ります。
ここでは、[挿入文字列](https://learn.microsoft.com/ja-jp/dotnet/fsharp/language-reference/interpolated-strings)を使ってみましょう。
```fsharp:F# Interactive
let kojin1 = $"{no1,2} {sei1}({yomi1})";;
```
```fsharp:F# Interactive（結果）
val kojin1: string = " 1 阿部(アベ)"
```
結合した文字列`kojin1`ができました。

::: note
挿入文字列に埋め込む値は`obj`でも構いません。
:::

#### 値を書き込む
::: note alert
fsi で上書きされたセルは <b>Excel で元に戻せません。</b>
値の書き込みには細心の注意を払ってください。
:::

セルへの書き込みは<b>`Value2`プロパティの使用が簡潔です。</b>
`Value2`プロパティを使って、文字列`kojin1`をセル E2 に書き込んでみます。

F# は**プロパティのセットに`<-`を使います。**
```fsharp:F# Interactive
ws.Range("E2").Value2 <- kojin1;;
```
Excel のセル E2 に文字列が表示されるはずです。

::: note
`Value2`でセットされた文字列や数値は Excel 側で解釈してくれます。
たとえば、文字列 "20240101" は数値、 "2024/01/01" は日付として解釈されます。
:::

#### `Value`プロパティを使う場合
`Value`による書き込みは**引数を省略できません。**
文字列`kojin1`をセル G2 に書き込んでみます。
```fsharp:F# Interactive（失敗します）
ws.Range("G2").Value() <- kojin1;;  // Value() は引数が必要
```
```fsharp:F# Interactive（エラー）
error FS0193: 内部エラー: 入力は負以外である必要があります。 (Parameter 'n')
```
引数を渡せば動作します。
```fsharp:F# Interactive
ws.Range("G2").Value(XlRangeValueDataType.xlRangeValueDefault) <- kojin1;;
```
または
```fsharp:F# Interactive
ws.Range("G2").Value(null) <- kojin1;;
```

::: note
`Value`プロパティは文字列や数値の他に`System.DateTime`型なども渡すことができます。
:::

#### `Cells[,]`プロパティも使えます
セル I2 （2行9列）に書き込んでみます。
```fsharp:F# Interactive
ws.Cells[2, 9] <- kojin1;;
```
ドキュメントを見ると`Item`プロパティはセット可能です。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/e8ccf6b5-6735-f4cb-1f7c-bfac34718601.png width=480px>

単独セルの値をコピーするだけなら、VBA 的な書き方が可能です。
```fsharp:F# Interactive
ws.Cells[1, 5] <- ws.Cells[1, 1];;
```
for ループで連続コピーも可能です。
```fsharp:F# Interactive
for row in 2..6 do
    ws.Cells[row, 5] <- ws.Cells[row, 1]
;;
```

## 一旦、後始末しましょう。
お疲れさまでした。

ここまでは F# を使うメリットが見えませんが、**[実例編](https://qiita.com/Mt_SQ/items/59f545a2d20fe1eaf73f)では VBA では得られない F# の強力さと爽快感**を書きます。

では、**重要な後始末**です。
いくら注意していても「COMオブジェクトの解放漏れ」をやらかします。
この件に関してはネット上でも長年議論が続いてますし、説明もできません。
ということで、次の記事などを参考にしてアレンジしています。

https://qiita.com/mima_ita/items/aa811423d8c4410eca71

https://learn.microsoft.com/ja-jp/archive/blogs/office_client_development_support_blog/office-5

これらの記事で指摘されている「中間オブジェクト」（暗黙的に作成されるオブジェクト）の件も承知していますが、本稿では見なかったことにしています。

::: note warn
本稿中に隠れている「中間オブジェクト」の例
`ex.Workbooks.Add()` --- 途中に`Workbooks`ができる
`wb.Worksheets[1]` --- 途中に`Sheets`ができる
`ws.Cells[2, 2]` --- 途中に`Range`ができる
`ws.Range("A2").Value()` --- 途中に`Range`ができる
大半のコードで「中間オブジェクト」が生じています。
:::


以下のコードで終了時の処理を行います。
```fsharp:F# Interactive
;;
// ↑ 終了処理の前に式や関数を評価しておくこと
Marshal.ReleaseComObject(cell4)
Marshal.ReleaseComObject(cell3)
Marshal.ReleaseComObject(cell2)
Marshal.ReleaseComObject(cell1)
Marshal.ReleaseComObject(ws)
wb.Close()  // ファイル保存確認あり
// wb.Close(true)  // 上書き保存して閉じる
// wb.Close(false) // 保存しないで閉じる
Marshal.ReleaseComObject(wb)
ex.Quit()
Marshal.ReleaseComObject(ex)
System.GC.Collect()
System.GC.WaitForPendingFinalizers()
System.GC.Collect()
;;
```
これまでの経験で注意してきた点を記します。

**● <font color=red>終了処理の前に`;;`を置く**</font>
詳しくは実例編に書きますが、fsi を使った処理に慣れてくると、終了処理までのコードを一気に流してしまうようケースも出てきます。
上の例のように終了処理前に`;;`を置き、式や関数の評価を済ませておかないと、環境にもよりますが、隠れ Excel プロセスが発生します。

**● fsi で起動した Excel は、fsi で終了させる**
もし、自分で Excel を閉じてしまった場合は、下記コードから`wb.Close()`, `ex.Quit()`を抜くことになりますが、プロセスが残る可能性は大きくなります。

**● let 束縛した Excel のオブジェクトをこまめに解放（`Marshal.ReleaseComObject`）する。**
できれば、作業の途中でも COM オブジェクトを使い終わった段階で解放してください。

::: note alert
**未解放の識別子を使い回し（上書き）しないでください**
COM オブジェクトが生き埋めになります。
特にループ内は要注意です。
:::

fsi の場合、<b>COM オブジェクトが`it`で返ってきた場合は、すぐに`it`を解放</b>してください。
例えば、セル A1 の内容を確認しようとして、うっかり`.Value2`を忘れると･･･

```fsharp:F# Interactive
ws.Range("A1");;
```
長いシーケンスが返ってきます...

```fsharp:F# Interactive（結果）
val it : Range =
  seq
    [seq
       [seq
          [seq
     （以下省略）
```
画面が埋め尽くされてしまいますが、スクロールアップして確認すると`it`は`Range`オブジェクトです。

すかさず
```fsharp:F# Interactive
Marshal.ReleaseComObject(it);;
```
で、解放してください。
未解放のまま新たな`it`が作成されると「生き埋め」状態になります。

さて、先ほど実行した`cell1.Value()`で返された`it`の場合は？
```fsharp:F# Interactive（結果）（再掲）
val it : obj = "阿部"
```
この`it`も`obj`ですが、中身は COM オブジェクトではなく`string`なので解放不要です。

先ほど作成した`cell2`も`obj`ですが、こちらは<b>中身が COM オブジェクト`Range`なので解放しました。</b>

後始末を忘れて fsi を閉じたり、オブジェクト解放を怠ると Excel を終了してもプロセスだけが残り、メモリを消費します。

### プロセスが残っていないか確認しましょう
Excel が全て閉じられていても次の手順で確認しましょう。
```powershell:PowerShell 7
ps excel
```
```powershell:PowerShell 7 ＜成功＞（プロセス "excel" が見つからない）
Get-Process: Cannot find a process with the name "excel". Verify the process name and call the cmdlet again.
```
```powershell:PowerShell 7 ＜失敗＞（見えないところで Excel が生存）
 NPM(K)    PM(M)      WS(M)     CPU(s)      Id  SI ProcessName
 ------    -----      -----     ------      --  -- -----------
     48    65.55     115.77       2.25    5880   7 EXCEL
```
失敗した場合は以下のコマンドで Excel のゾンビを退治します。
```powershell:PowerShell 7
kill -name excel
```
単に`kill`するだけでは対策が不十分との意見もありますが、個人利用の範囲ではこの程度で十分かと思います。
あとは自己責任でお願いします。

## おわりに
fsi.exe と dotnet fsi において F# の言語機能は全く同じです。
対象のフレームワークが .NET Framework か .NET 8 かの違いになります。

フレームワークの違いが影響する例として .NET Framework では、`GetActiveObject`メソッドが使えます。
`System.Runtime.InteropServices.Marshal.GetActiveObject("Excel.Application")`
により、既に起動している Excel も操作可能です。

一方で、.NET 8 には`GetActiveObject`がありません。
互換性が必要な場合は API のドキュメントを確認しておきましょう。

以下、「学校業務でありそうな作業」を例にした「[実例編](https://qiita.com/Mt_SQ/items/59f545a2d20fe1eaf73f)」に続きます。