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

（2024.8.31 更新）
「準備編」の書き換えに合わせて、大幅に書き換えました。

初回投稿時の後半は、「ワークシートの新規作成」に関する内容でしたので、ClosedXML を利用した方法に変更して、独立した記事にする予定です。

## はじめに
[準備編](https://qiita.com/Mt_SQ/items/f3dc536eece8a750c82b)に続いて実例編に進みます。
（記事風に書いてはいますが個人の備忘メモです。）

実例は「学校業務でありそうな」名簿処理を題材にしています。

コードの全体的な構成は人に見せられるようなものではありませんが、Excel のワークシートと F# のコレクションとの「相性」を中心に見ていただければと思います。

F# を使えば VBA よりも複雑な処理を完結に記述できます。
また、マクロのようにブック内で管理する必要が無いところもメリットです。

また、Excel の直接操作に必要な COM オブジェクトは「厄介者扱い？」されていますが、実用上、支障なく使うための方策についてまとめておきました。

## セル範囲の操作
準備編では単独セルの操作まで確認しましたが、セル範囲の操作に進みます。
VBA の場合は、セル範囲を`For`ループなどで走査することが多いと思います。
一方、F# の場合は、セル範囲を２次元配列として取得し、処理後に２次元配列を書き込む方法が向いています。

もちろん、VBA でも２次元配列を使うことはできますが言語仕様が貧弱です。

対して、F# は簡潔なコード記述に加えて、標準のコレクション関数が強力です。
以降、コレクション操作が頻出しますのでドキュメントへのリンクを張っておきます。

https://learn.microsoft.com/ja-jp/dotnet/fsharp/language-reference/fsharp-collection-types

### 準備
サンプルとして、架空の名簿データ40人分を[CSVファイル](https://drive.google.com/file/d/1JOkK1VZRaimod3GwzKWOV8DQtUrT-JfA/view?usp=sharing "sample.csv")で用意しました。
Excel で開き、列幅を調整するとこのようなイメージです。

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/e44fd7bb-5dda-932c-18fc-1c204a38eebf.png width=640px>

**内容を確認できたら Excel 形式で保存・終了してください。**
以降はファイル名を <i>"C:\Users\user\Desktop\sample.xlsx"</i> として進めます。

### fsi で データ範囲の値を取得する
fsi に以下のコードを貼り付けると、Excel が起動してワークシートの名簿データを取得します。

罫線を引く関数などもここに置きました。

::: note warn
アセンブリへの参照設定（詳しくは[準備編](https://qiita.com/Mt_SQ/items/f3dc536eece8a750c82b)）や、入出力のフォルダ名・ファイル名は自身の環境に合わせて変更します。
:::

```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 // System 名前空間をインポート
open Microsoft.Office.Interop.Excel // open System より後に置く
open type Runtime.InteropServices.Marshal // COM オブジェクトの解放用
open type XlBordersIndex // 罫線に関する記述を簡潔にするため
open type XlBorderWeight
open type XlLineStyle

let path: string = @"C:\Users\user\Desktop\sample.xlsx"
let outpath: string = @"C:\Users\user\Desktop\sample_out.xlsx"
let ex: _Application = ApplicationClass(Visible = true)
let wb: _Workbook = ex.Workbooks.Open(Filename = path, ReadOnly = true) // 読み取り専用で開く
let ws: _Worksheet = downcast wb.Worksheets[1]

// セル範囲に格子状罫線を記入する関数(外側：xlThin, 内部：xlHairline)
let bordersSet (rng: Range) =
    rng.Borders[xlInsideHorizontal].LineStyle <- xlContinuous
    rng.Borders[xlInsideHorizontal].Weight <- xlHairline
    rng.Borders[xlInsideVertical].LineStyle <- xlContinuous
    rng.Borders[xlInsideVertical].Weight <- xlHairline
    rng.BorderAround(LineStyle = xlContinuous, Weight = xlThin) |> ignore

// 名簿データの取得 (識別子 meibo)
let meibo: obj[,] = downcast ws.Range("A1").CurrentRegion.Value() |> Array2D.rebase
;;
```
準備編よりもコードが長くなりましたが内容は平易です。

以降の実例で`Math`や`DateTime`クラスなどを使用するため、`System`名前空間もインポート(`open System`)しました。（ほぼ必須です。常に記述しておいてもよいでしょう。）

COM オブジェクト解放時の`GC`クラス（ガベージコレクタ）も`System`名前空間にあります。

::: note warn
<b>`open Microsoft.Office.Interop.Excel` は `open System` より後に記述</b>します。
.NET 8 には`System`名前空間に`Range`構造体があるため、Excel の`Range`とメンバー名が衝突します。
修飾のない`Range`には、後に記述した`Microsoft.Office.Interop.Excel`名前空間が適用されます。
:::

#### 名簿データ (識別子 `meibo`)について
単独セルに限らず、セル範囲に対する`Range.Value`も`obj`を返します。
ただし、実体は２次元配列ですので、`obj[,]`へのダウンキャストが必要です。

配列のベースは Excel の既定値 [1, 1] なので、`Array2D.rebase`でベースを [0, 0] にしておきます。
リベースは状況に応じて使います。

fsi の応答画面の一部です。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/6a7bcbfc-f488-cef5-d87f-94076b0f05ab.png width=640px>

以下40番まで続きます。

::: note
`meibo`の各要素に注目すると、数値は`float`、日付は`DateTime`であることがわかります。
要素の型が混在しているため、配列の型は`obj[,]`である必要があります。
:::

#### あらためて`Value`と`Value2`について
「準備編」でも触れたように、`Range.Value2`の場合、日付はシリアル値（`float`）となります。

`Range.Value2`を使った場合を示します。生年月日がシリアル値です。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/aa70a043-79a0-e5c5-b5cf-03d2303624ff.png width=640px>

ドキュメントには次のように書いてあります。

> Value2 プロパティでは、通貨型 (Currency) および日付型 (Date) のデータ型を使用しない点のみが、Value プロパティと異なります。 倍精度浮動小数点型 (Double) を使用することにより、これらの 2 種類のデータ型の値を浮動小数点数として返すことができます。

https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.office.interop.excel.range.value2?view=excel-pia#--

「日付型」の例は上記のとおりですが、「通貨型」についても検証結果を示します。
名簿シートの未使用セル G2 に 3000 を入力し、書式を「通貨」に設定しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/6b90fcb3-653a-d897-5c13-596c94af4f9a.png)

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/74f7118e-fda6-3d63-fd9a-85135f2dc58a.png width=400px>

＜結論＞
「通貨」として書式設定されたセルの数値は
`Value`で取得した場合は`decimal`（`System.Decimal`）にしかキャストできない。
`Value2`の場合は`float`（`System.Double`）にしかキャストできない。

ワークシートに金銭データが含まれる場合は、取り扱いに配慮する必要があります。

## 実例１：座席表
以下、名簿データ（`meibo`）と、過去に実務で作成したコードを記事用にアレンジして「実例」として紹介します。

最初は、名簿をもとに、縦６列で右上（教室右前方）から番号順にした座席表を作ってみます。

先ほどの`meibo`から、fsi で下図のような「座席表」シートを作成します。
実際には格子状に罫線が引かれます。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/4b2be8e8-de1a-96c7-239f-eb2e4043ea3d.png width=640px>

180°回転して、教壇からの目線で作った「座席表(教員用)」シートも同時に作成してみます。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/55fc7088-ac81-29ab-f7ee-9fa1ab9c65ac.png width=640px>

次のコードを fsi に貼り付けると２枚の座席表が瞬時に作成されます。
```fsharp:F# Interactive
// 名簿データ（引数 data）から２枚の座席表シートを作成する関数
let zaseki (data: obj[,]) =
    // 人数から縦横の数を算出（正方形に近い縦長とする）
    let num = (data |> Array2D.length1) - 1 // 人数
    let row = num |> (Math.Sqrt >> Math.Ceiling >> int) // 縦の数
    let col = (float num / float row) |> (Math.Ceiling >> int) // 横の数
    // 名簿データから２次元配列を作成
    let zaseki2D: string[,] =
        [ 1..num ]
        // |> List.randomShuffle // ランダムに配置する場合
        |> List.map (fun i -> $"{i, 2}\x0A{data[i, 1]}")
        |> List.splitInto col
        |> List.map (fun lst -> if lst.Length < row then lst @ [ null ] else lst)
        |> List.rev
        |> List.transpose
        |> array2D
    // zaseki2d を半回転した２次元配列（教員目線の配置）
    let zaseki2D_R =
        zaseki2D
        |> Seq.cast<string> // ２次元配列を平坦化
        |> Seq.rev // 反転させて
        |> Seq.chunkBySize col // もう一度切り分ける
        |> array2D
    // 「座席表」ワークシートを作成
    let w, h = 14, 35 // セルの列幅 と 行の高さ
    let wsZaseki = wb.Worksheets.Add() :?> _Worksheet
    wsZaseki.Name <- "座席表"
    let rngZaseki = wsZaseki.Range("A1").Resize(row, col)
    rngZaseki.Value2 <- zaseki2D
    rngZaseki.ColumnWidth <- w
    rngZaseki.RowHeight <- h
    rngZaseki |> bordersSet
    ReleaseComObject(rngZaseki) |> ignore // 使わない返値は破棄(ignore)
    ReleaseComObject(wsZaseki) |> ignore
    // 「座席表(教員用)」ワークシートを作成
    let wsZaseki_R = wb.Worksheets.Add() :?> _Worksheet
    wsZaseki_R.Name <- "座席表(教員用)"
    let rngZaseki_R = wsZaseki_R.Range("A1").Resize(row, col)
    rngZaseki_R.Value2 <- zaseki2D_R
    rngZaseki_R.ColumnWidth <- w
    rngZaseki_R.RowHeight <- h
    rngZaseki_R |> bordersSet
    ReleaseComObject(rngZaseki_R) |> ignore
    ReleaseComObject(wsZaseki_R) |> ignore
;;
// 関数 zaseki に名簿を渡して座席表を作成する
meibo |> zaseki
;;
```
### コードについて
関数`zaseki`は、名簿データを引数にして２枚の座席表シートを作成します。
処理手順を簡単に説明しておきます。

#### 机の配置
正方形を基本にして、余りが出たら縦方向に伸ばします。
たとえば40人の場合、横６人×縦７人に収めます。

#### 生徒の配置（`zaseki2D`について）
生徒の配置には、F# のコレクション関数が力を発揮します。
40人を横6人×縦7人の範囲に収める様子を示します。

次のコードは`zaseki2D`を簡素化して「1行単位で実行（`;;`）」します。
fsi に貼り付けると処理の様子が一目で確認できます。

これが対話環境の強みです。`it`と`;;`に加えて、パイプ`|>`が使える分、Python の対話環境よりも使い勝手がよいと思います。
```fsharp:F# Interactive
[ 1..40 ];;
it |> List.splitInto 6;;
it |> List.map (fun lst -> if lst.Length < 7 then lst @ [ 0 ] else lst);;
it |> List.rev;;
it |> List.transpose;;
it |> array2D;;
```
実行結果です。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/a888b8c7-b18b-9ed6-3fe2-9b2d52781a7e.png width=640px>

言葉にすると余計にわかりにくいかもしれませんが
* 1～40の整数リストから
* 6分割して「リストのリスト」を作る
* 長さが足りないところには0を追加して、完全な6行7列を作る
* 「行」を反転する（この後、行列転置するので実質は「列」の並びを反転）
* 行列転置　（7行6列で右側の列が若い番号になる）
* 「リストのリスト」を`array2D`演算子で「２次元配列」にする

といった手順です。

#### 教員目線の配置（`zaseki2D_R`について）
`zaseki2D`を180°回転させて、教員目線の配置（`zaseki2D_R`）を作る手順も、`zaseki2D`の手順確認に続けて実行してみます。
```fsharp:F# Interactive
it |> Seq.cast<int>;;
it |> Seq.rev;;
it |> Seq.chunkBySize 6;;
it |> array2D;;
```
`zaseki2D`の動作確認に続けた様子です。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/a640455a-d8cd-38cd-13ad-80fd6bda2991.png width=640px>
180°回転しています。

ここまでの処理でループは使っていません。
同じ処理を VBA で記述するとしたら、かなり面倒なループを書くことになりそうです。:scream:

座席の配置を２次元配列で表現できれば、あとはセル範囲（`Range`）に投げ込むだけです。

#### `ignore`について
メソッドの中には値を返すものがあります。「メソッドの機能が目的だが返値は使わない」こともあります。
F# の関数や式を記述する際、その内部で呼び出したメソッドの返値を無視すると fsi は警告を発します。
`ignore`は引数を破棄する関数ですので、不要な返値を棄てるために使用します。

#### COM オブジェクトは解放します
しつこいようですが、関数や式の内部でバインド（let 束縛）された COM オブジェクトは内部で解放することを忘れないようにします。
ワークシート（`_Worksheet`）やセル範囲（`Range`）など COM オブジェクトに関連する記述は、まとめるように心掛け、使い終わったら早めに解放します。

#### おまけ
`zaseki2D`の頭でコメントアウトされている部分を生かすと、席順がランダムになります。
```fsharp:コードの該当箇所
    let zaseki2D: string[,] =
        [ 1..num ]
        |> List.randomShuffle // ランダムに配置する場合
```
（試してみる場合は、２枚の**座席表を削除してから実行**してください。）

::: note warn
`randomShuffle`は F# 8.0.400 で追加されたものです。(2024年7月)
* [fsharp/docs/release-notes/.FSharp.Core/8.0.400.md](https://github.com/dotnet/fsharp/blob/26ad522c827b560a5e6c9a24d17bd00fba8e1af2/docs/release-notes/.FSharp.Core/8.0.400.md#added)
* [F# RFC FS-1135 - Random functions for collections (List, Array, Seq)](https://github.com/fsharp/fslang-design/blob/main/RFCs/FS-1135-random-functions-for-collections.md#f-rfc-fs-1135---random-functions-for-collections-list-array-seq)
:::

## 実例２：男女比を考慮したグループ分け
<b><font color=green>※ 実例１に続けて作業を進めます。</font></b>

たとえば６班編制で清掃班を作る場合、単なる名簿順では男女の構成に偏りが生じます。
更衣室やトイレ清掃の担当時などに影響が出るので、男女比ができるだけ均等になるように男女それぞれを６分割して組み合わせてみます。

表のイメージです。（完成版はもう少し書式設定を追加しています。）
![seisou.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/3d4387e9-c501-92ed-5c16-33f11c9dd2c4.png)

次のコードを fsi に貼り付けると「清掃班」シートが作成されます。
```fsharp:F# Interactive
// 名簿データから「清掃班」シートを作成する関数
let seisou (data: obj[,]) =
    let num = (data |> Array2D.length1) - 1 // 人数
    let gn = 6 // 班の数
    // 男女均等になるよう班編制する
    let group: int list list =
        let f, m = [ 1..num ] |> List.partition (fun e -> data[e, 3] = "女")
        let f = f |> List.rev |> List.splitInto gn |> List.rev
        let m = m |> List.splitInto gn
        (f, m) ||> List.map2 (@) |> List.map List.sort
    // データの位置決め用にタプル(累積人数, 各班の人数)のリストを用意する
    let groupOffsetAndNum: (int * int) list =
        group
        |> List.map List.length
        |> fun lst -> ((0, lst[0]), lst[1..])
        ||> List.scan (fun (offset, n0) n -> (offset + n0, n))
    // 「清掃班」ワークシートを新規作成
    let ws_seisou = wb.Worksheets.Add() :?> _Worksheet
    ws_seisou.Name <- "清掃班"
    // 表の項目と書式を設定
    ws_seisou.Range("A1:D1").Value2 <- [| "班"; "番号"; "氏名"; "性別" |]
    ws_seisou.Range("A1:D1").HorizontalAlignment <- Constants.xlCenter
    ws_seisou.Range("A1:D1") |> bordersSet
    // 名簿データと班編制データ(group)から必要な情報をセル範囲にセット
    ws_seisou.Range("B2").Resize(num, 3).Value2 <-
        group
        |> List.concat
        |> List.map (fun e -> [ data[e, 0]; data[e, 1]; data[e, 3] ]) // 番号, 氏名, 性別
        |> array2D
    // セルの書式設定
    ws_seisou.Range("B2").Resize(num, 1).NumberFormatLocal <- "??"
    ws_seisou.Range("B2").Resize(num, 1).HorizontalAlignment <- Constants.xlCenter
    ws_seisou.Range("D2").Resize(num, 1).HorizontalAlignment <- Constants.xlCenter
    // 班単位で罫線を引き班番号を記入
    groupOffsetAndNum
    |> List.iteri (fun i (offset, n) ->
        ws_seisou.Range("A2").Resize(n, 4).Offset(offset, 0) |> bordersSet
        // 班番号を全角で記入
        ws_seisou.Range("A2").Offset(offset, 0).Value2 <- $"{i + 1}班" |> ex.WorksheetFunction.Dbcs
        // 班番号の余計な罫線を消す
        ws_seisou
            .Range("A2")
            .Offset(offset, 0)
            .Resize(n, 1)
            .Borders[xlInsideHorizontal]
            .LineStyle <- xlLineStyleNone)
    // 列幅の自動調整
    ws_seisou.Range("A1").CurrentRegion.EntireColumn.AutoFit() |> ignore
    // COM オブジェクトの解放
    ReleaseComObject(ws_seisou) |> ignore
;;
// 用意したデータから清掃班編制表を作成する
meibo |> seisou
;;
```

### コードについて
実質的なデータ処理はコード前半の数行です、残りはシートの書式設定に費やしています。
#### 班編制（`group`）の作成手順

::: note warn
男女比を考慮した班編制は簡易的なものです。
状況に応じて修正が必要かと思います。
:::

班編制（`group`）の式はわかりにくいので、40人を6班編成することを前提に、要所の部分だけ1行単位で実行して確認します。
次のコードを fsi に貼り付けてください。
```fsharp:F# Interactive
// f：女性 , m：男性 とします
let f, m = [ 1..40 ] |> List.partition (fun e -> meibo[e, 3] = "女");;
let f = f |> List.rev |> List.splitInto 6 |> List.rev;; // 女性は末尾側の人数を大きくする
let m = m |> List.splitInto 6;;
(f, m) ||> List.map2 (@) |> List.map List.sort;;
```
実行結果です。
名簿シートや清掃班シートと照合してみると、男女を組み合わせる様子がわかると思います。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/6854fc3a-a306-ee22-e611-de53ac4052f2.png width=640px>
`List.splitInto`関数は割り切れない場合に、リストの先頭側の要素数が大きくなります。
そのため、女性を降順にしてから分割し、末尾側の要素数が大きくなるように工夫しています。

最後に男女６グループをそれぞれ連結し（`@`演算子）、班ごとに昇順ソートして完了です。


#### 班単位の位置決めについて（`groupOffsetAndNum`）
完成イメージを見ると、班の番号や班単位の罫線引きなどが必要です。
そのため、各班のセル上での位置情報を別に用意した方がよさそうです。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/f935c9d0-53ba-eaa4-1b25-523524a658f2.png width=640px>
各班の先頭の位置（赤矢印）と人数（`groupOffsetAndNum`）を求めます。
1番の生徒の位置を 0 とします。

これも、次のコードで「ステップ実行」してみます。
先ほど、班編制の作成手順確認で得られた「`it`」をそのまま利用します。
次のコードを貼り付けてください。
```fsharp:F# Interactive
it |> List.map List.length;;
it |> fun lst -> ((0, lst[0]), lst[1..]);;
it ||> List.scan (fun (offset, n0) n -> (offset + n0, n));;
```
先ほど得られた班編制（`it`）から班の人数のリストを作り、セル上の行位置および行数（`groupOffsetAndNum`）を作成する様子がわかります。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/294ae36b-5070-f63c-cdd3-86f0c7be11d9.png width=640px>

#### ワークシート関数の利用
F# からも Excel のワークシート関数を使うことができます。
コード中では、半角文字を全角文字に変換するため`WorksheetFunction.Dbcs`を利用しています。
これは、日本語版 Excel の`JIS`ワークシート関数に相当します。

ここからは寄り道です。せっかく Excel（`ex`）が生きているので、例として`Frequency`ワークシート関数で「度数分布」を求めます。
（ベースが [1, 1] の２次元配列が返されます。）
```fsharp:F# Interactive
([| 40; 25; 26; 27; 28; 29; 33; 34; 35; 10; 11; 12; 13 |], [| 19; 29; 39 |])
|> ex.WorksheetFunction.Frequency :?> obj[,]
|> Array2D.map Convert.ToInt32
;;
it[2, 1]  // 要素にアクセスする場合
;;
```
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/2a8b6b91-eac8-86b5-4b62-852630d2ed36.png width=640px>

各段階の度数が得られました。
|段階    |度数|
|:-----:|:--:|
|～ 19   |4  |
|20 ～ 29|5  |
|30 ～ 39|3  |
|40 ～   |1  |

::: note warn
ワークシート関数がトラブルを招くケースがあります。
次項「終了処理とスクリプト化およびゾンビプロセス対策」で触れます。
:::

## 実例３：満年齢
内容的には年齢計算より、２次元配列の加工が主になります。
ワークシート関数でも普通にできることですが、F# を使って２次元配列を加工する例として載せておきます。

あらためて、名簿データを使って、「今日」の生徒？の満年齢を計算します。（データは初回投稿時のままですから、もう卒業生ですね。）

::: note warn
厳密な年齢計算が求められる場面では対策が必要です。
法律では「誕生日の前日に1歳加える」と解釈する場合があります。
[（参考）４月１日生まれの子どもは早生まれ？](https://houseikyoku.sangiin.go.jp/column/column011.htm)
[「17歳」でも投票できる？！](https://www.nhk.or.jp/senkyo/chisiki/ch18/20160430.html)
:::

意外？なことに Excel でも満年齢の計算には工夫が必要です。
検索すると「隠し関数 `DATEDIF`」の使用例が最も紹介されているようです。
（前世紀に人気だった [Lotus 1-2-3](https://ja.wikipedia.org/wiki/Lotus_1-2-3) のサポート用らしい。当方はモッサリ動作？の [Multiplan](https://ja.wikipedia.org/wiki/Microsoft_Multiplan) 派でしたが...）

さて、簡易的に満年齢を求めるのなら、8桁整数（yyyymmdd）の引き算で十分です。
名簿データの隣（F列）に書き込んでみます。

次のコードを fsi に貼り付けると、名簿シートに「今日」の満年齢が追加されます。
（ポイントを絞るため、人数が40人であることを前提にしています。）
```fsharp:F# Interactive
ws.Range("F2").Resize(40, 1).Value2 <-
    meibo[1.., 4] // スライスによって生年月日のデータだけを抜き出す
    |> Array.map (fun date -> (int $"{DateTime.Today:yyyyMMdd}" - int $"{date:yyyyMMdd}") / 10000)
    |> Array.singleton
    |> Array.transpose
    |> array2D

ws.Range("F1").Value2 <- "年齢"
ws.Range("F1").EntireColumn.AutoFit()
;;
```
次のコードで配列の処理手順だけ確認しておきます。
F# で２次元配列をスライスする際、単一の行または列を切り出すと次元が下がります。
* （参考）[配列スライスと多次元配列](https://learn.microsoft.com/ja-jp/dotnet/fsharp/language-reference/arrays#array-slicing-and-multidimensional-arrays)

`Array.singleton`で２次元配列に戻したうえで行列転置します。
```fsharp:F# Interactive
meibo[1.., 4]
|> Array.map (fun e -> (int $"{DateTime.Today:yyyyMMdd}" - int $"{e:yyyyMMdd}") / 10000);;
it |> Array.singleton;;
it |> Array.transpose;;
it |> array2D;;
```
実行結果です。（2024年8月26日現在の年齢）
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/d08714f9-e899-7456-a3fd-bb012b026231.png width=640px>

最後には、満年齢の１次元配列が縦方向の１列の２次元配列に変換されています。

### 別解
行列転置にワークシート関数を利用するともっと簡潔になります。
```fsharp:F# Interactive
ws.Range("F2").Resize(40, 1).Value2 <-
    meibo[1.., 4]
    |> Array.map (fun date -> (int $"{DateTime.Today:yyyyMMdd}" - int $"{date:yyyyMMdd}") / 10000)
    |> ex.WorksheetFunction.Transpose :?> obj[,]

ws.Range("F1").Value2 <- "年齢"
ws.Range("F1").EntireColumn.AutoFit()
;;
```
こちらも動作確認をしてみます。
```fsharp:F# Interactive
meibo[1.., 4]
|> Array.map (fun date -> (int $"{DateTime.Today:yyyyMMdd}" - int $"{date:yyyyMMdd}") / 10000);;
it |> ex.WorksheetFunction.Transpose :?> obj[,];;
```
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/e23c522b-8d01-0f4f-afab-0a88a53adc3f.png width=640px>

## 終了処理とスクリプト化およびゾンビプロセス対策

::: note warn
ここで言う「ゾンビプロセス」は Excel 終了後もタスクマネージャで確認できる Excel のプロセスを指しています。
:::

実例紹介に一区切りついたので、終了処理とスクリプト化について触れます。
また、「COM オブジェクトの解放」に関する検証と、「ワークシート関数の使用に関するトラブル」についても付け加えます。

終了時のコードは準備編とほぼ一緒で「ファイル名をつけて保存」を追加しただけです。
```fsharp:F# Interactive
wb.SaveAs(outPath)
;;
// ↑ Excel の終了処理前に ;; を入れること
ReleaseComObject(ws)
wb.Close(SaveChanges = false) // 保存しないで閉じる
ReleaseComObject(wb)
ex.Quit()
ReleaseComObject(ex)
GC.Collect()
GC.WaitForPendingFinalizers()
GC.Collect()
;;
```

fsi を終了して、PowerShell で隠れ Excel をチェック（準備編を参照）します。
PowerShell 環境でない場合はタスクマネージャでも構いません。

### スクリプト化
一連のコードを UTF-8 のテキストで保存すれば、スクリプトファイルになります。
実行（式の評価）の際に使った「`;;`」は取り除いて構いません。

`.fsx`は F# スクリプトの標準的な拡張子です。fsi の起動時にコマンドラインの引数としてスクリプトファイルのパスを渡します。

公式ドキュメントに F# スクリプトの詳細が記載されています。
他のスクリプトの読み込みやコマンドライン引数へのアクセスなど、より便利に使うために必読です。

https://learn.microsoft.com/ja-jp/dotnet/fsharp/tools/fsharp-interactive/#scripting-with-f

### スクリプトファイルの実行で問題発生～とりあえずの解決法
さて、実例１～３を対話環境（fsi）へのコピペで動作確認後、上記の終了処理でゾンビプロセスは発生しませんでした。

ところが、<font color=red><b>３つのコードを一つに繋いだスクリプトファイルを走らせると、終了後も Excel のプロセスが残ってしまいます。</b></font>

なぜか、<font color=red><b>dotnet fsi でゾンビが生まれますが、fsi.exe では生まれません。</b></font>

スクリプト自体は正常動作するのですが、スクリプト実行のつどプロセスを`kill`するのは許容できません。

#### 現時点での対応策
検証を重ねて、とりあえず Excel のプロセスが残らない状況にたどり着きました。

答えはすでに示しています。
<b><font color=red>スクリプトであっても「終了処理の直前に`;;`を入れる」</font></b>ことでした。

検索してみたのですが、F# スクリプト内の`;;`の働きに関する情報を見つけることができませんでした。
しかしながら、<b><font color=red>スクリプト内でも`;;`が機能している</font></b>ことは間違いないようです。

簡単なスクリプトで検証を行い、その過程で再現できたことを簡単にまとめておきます。
* <b>fsi.exe は、乱暴なコードでも Excel のプロセスが残らない
（極端な話、COM オブジェクトの解放やガベージコレクションを抜いても大丈夫）
（fsi.exe の終了によって解放される？）
* <b>dotnet fsi は次の3つが必須（どれかが欠けてもダメ） ---> 下記【検証1】
(1) 終了処理の直前に`;;`を入れる
(2) COM オブジェクトの解放
(3) ガベージコレクションの強制
* <b>ワークシート関数を利用する場合は要注意 --->【検証2】
`obj`を返すものは要注意。キャストで回避できた。
* <b>dotnet fsi で「中間オブジェクトを作らない（本来、心がけるべきこと）」場合はガベージコレクションを強制せずともプロセスが残らない（でも、`;;`は必要） --->【検証3】

中間オブジェクトが生じないような記述を心がけることは大切です。潜在的な問題があることを認識しつつも、個人利用の範囲で問題が無ければ利便性を優先します。

よって、<b><font color=red>本記事では dotnet fsi で、上記2番目の対応により、プロセスが残らないことだけを目標にしています。</font></b>

#### 【検証1】
追実験用として、検証したスクリプトファイルを以下に貼っておきます。
検証する場合は UTF-8 のテキストファイルとして保存してください。
ここでは、ファイル名を「検証1.fsx」としておきました。
```fsharp:検証1.fsx
#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
open type Runtime.InteropServices.Marshal
open Microsoft.Office.Interop.Excel

let ex: _Application = ApplicationClass(Visible = false)
let wb: _Workbook = ex.Workbooks.Add()
let ws: _Worksheet = downcast wb.Worksheets[1]
;; // 必須
ReleaseComObject(ws)
wb.Close(false)
ReleaseComObject(wb)
ex.Quit()
ReleaseComObject(ex)
GC.Collect()
GC.WaitForPendingFinalizers()
GC.Collect()
```
**スクリプトを保存したら、PowerShell に以下をコピペします。**

```powershell:PowerShell 7
dotnet fsi .\検証1.fsx
ps excel
sleep 5
ps excel
```
スクリプト終了直後は Excel のプロセスが残っていますが、5秒後（`sleep 5`）には消えています。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/e68e045e-6309-a1b9-ffc8-d54f4985e189.png width=640px>

終了処理前の`;;`を抜いた場合は、5秒後でも Excel のプロセスが残っています。（ただし、10分ほど放置したら消えていました。）

#### 【検証1】(補足）
参考までに、「終了処理直前の`;;`」を抜いたコードを **対話環境（fsi）にコピペする**と、fsi.exe と dotnet fsi の両者ともに同じエラーを返します。このことが、スクリプトにも影響しているのかもしれません。

＜fsi.exe＞
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/0c8170ef-e7bb-3ccb-208f-e2b7ff6ef951.png width=640px>

＜dotnet fsi＞
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/81a4e85c-0dbc-d6bd-bbf1-4d7bc156ebd5.png width=640px>

#### 【検証2】(ワークシート関数の利用時は要注意）
とりあえず、これで解決したかと思っていましたが、実例３の「[別解](https://qiita.com/drafts/b75ae9699e77798c5feb/edit#%E5%88%A5%E8%A7%A3)」を走らせると再び Excel のプロセスが残ります。（※ 記事中のコードは修正済みです。）

おそらく`WorksheetFunction.Transpose`が問題を起こしているようです。
COM オブジェクトかどうかは簡単に確認できます。（`System.Type.IsCOMObject`）
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/27cc955c-d1b9-3894-b155-56e98779022a.png width=640px>

`WorksheetFunction.Transpose`は COM オブジェクトではなく`obj[,]`（`System.Object[,]`）です。
当然 COM オブジェクトの解放ができません。

さらに検証します。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/68298c23-00c6-e78c-2e34-a27efd04b7d8.png width=640px>

`WorksheetFunction.Transpose`は`Value2`と同様に`obj`を返します。（上図上部）
中身は`obj[,]`ですからダウンキャストが可能です。（上図下部）

当初、「別解」では`Range.Value2`にセットするだけなので、キャストを省略していました。
そもそも、`Range.Value2`は`obj`を出し入れするので、`WorksheetFunction.Transpose`の返値はそのままセット可能です。

とにかく怪しいのはここだけだったので、返値を`obj`から`obj[,]`へ**ダウンキャストしてみたらあっさり解決しました。**

まずは、ダウンキャスト（`:?> obj[,]`）をコメントアウトしたスクリプトで検証します。

```fsharp:検証2.fsx
#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
open type Runtime.InteropServices.Marshal
open Microsoft.Office.Interop.Excel

let ex: _Application = ApplicationClass(Visible = false)
let wb: _Workbook = ex.Workbooks.Add()
let ws: _Worksheet = downcast wb.Worksheets[1]

// 下の行でキャスト（末尾の ":?> obj[,]" の有無）が影響する
[ [ 1; 2 ] ] |> array2D |> ex.WorksheetFunction.Transpose // :?> obj[,]

;; // 必須
ReleaseComObject(ws)
wb.Close(false)
ReleaseComObject(wb)
ex.Quit()
ReleaseComObject(ex)
GC.Collect()
GC.WaitForPendingFinalizers()
GC.Collect()
```

どうせゾンビが発生するので`kill`を追加しておきました。
```powershell:PowerShell 7
dotnet fsi .\検証2.fsx
ps excel
sleep 5
ps excel
kill -Name excel
```
キャストしないとゾンビが発生します。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/44256f89-26aa-3392-3343-437a724d8ed9.png width=640px>

キャストしたスクリプトではゾンビは生まれません。５秒後には消滅しています。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/dec97f95-45b1-879a-8d84-5cd756b76b14.png width=640px>

ところで、ここまで示したコード内にもワークシート関数（`WorksheetFunction.Dbcs`と`WorksheetFunction.Frequency`）が使われています。

まず、`WorksheetFunction.Frequency`は、コード中でダウンキャストしています。
また、`WorksheetFunction.Dbcs`は`string`（`System.String`）を返すのでキャスト不要です。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/2b732526-b34d-c1d6-d0f3-a93357b6095b.png width=640px>


#### 【検証3】（中間オブジェクト作らないときはガベージコレクション不要）
参考までに、中間オブジェクトを作らないコードならばガベージコレクションを省いてもプロセスは残らないことを確認しました。
```fsharp:検証3.fsx
#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
open type Runtime.InteropServices.Marshal
open Microsoft.Office.Interop.Excel

let ex: _Application = ApplicationClass(Visible = false)
let wbs: Workbooks = ex.Workbooks
let wb: _Workbook = wbs.Add()
let wss: Sheets = wb.Worksheets
let ws: _Worksheet = downcast wss[1]
;; // 必須
ReleaseComObject(ws)
ReleaseComObject(wss)
wb.Close(false)
ReleaseComObject(wb)
ReleaseComObject(wbs)
ex.Quit()
ReleaseComObject(ex)
```
検証します。
```powershell:PowerShell 7
dotnet fsi .\検証3.fsx
ps excel
sleep 5
ps excel
```
結果の画面は省略しますが、ゾンビは生まれていません。

**【検証環境】** （準備編から再掲）
* 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)

::: note warn
あくまでも、当方の環境で確認できた事象のまとめでしかありません。
参考程度に取り扱ってください。
:::

## コーディング環境について
お付き合いいただきありがとうございます。

F# で Excel 操作を記述する場合、コーディング環境は重要です。
多くの方が紹介しているように VSCode + Ionide for F# 拡張機能は最低限必要かと思います。

記事中の実行画面は <i>Windows Terminal</i> で示しましたが、実際は VSCode と 内蔵ターミナルで作業は完結します。

下図のように、コード補完も問題ありませんし、個人の開発環境としては十分すぎます。
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209885/1d668a88-e662-0dc7-1747-b7cb8c1309a7.png width=640px>

## 参考
2012年頃、書店で偶然手にした「[実践F#　関数型プログラミング入門](https://gihyo.jp/book/2011/978-4-7741-4516-7)」 （荒井省三、いげ太 著　技術評論社 2011年）が F# にハマるきっかけでした。
何度も読み返しましたが、12年かけて半分くらい消化できたようです。

F# を使った Excel 操作のきっかけは「[Scripting Excel tests with F#](https://brandewinder.com/2011/11/06/Scripting-Excel-tests-with-F/)」でした。
F# の実践的な学習素材として、Excel を使う手もアリかなと思った次第です。