(2024.8.31 更新)
「準備編」の書き換えに合わせて、大幅に書き換えました。
初回投稿時の後半は、「ワークシートの新規作成」に関する内容でしたので、ClosedXML を利用した方法に変更して、独立した記事にする予定です。
はじめに
準備編に続いて実例編に進みます。
(記事風に書いてはいますが個人の備忘メモです。)
実例は「学校業務でありそうな」名簿処理を題材にしています。
コードの全体的な構成は人に見せられるようなものではありませんが、Excel のワークシートと F# のコレクションとの「相性」を中心に見ていただければと思います。
F# を使えば VBA よりも複雑な処理を完結に記述できます。
また、マクロのようにブック内で管理する必要が無いところもメリットです。
また、Excel の直接操作に必要な COM オブジェクトは「厄介者扱い?」されていますが、実用上、支障なく使うための方策についてまとめておきました。
セル範囲の操作
準備編では単独セルの操作まで確認しましたが、セル範囲の操作に進みます。
VBA の場合は、セル範囲をFor
ループなどで走査することが多いと思います。
一方、F# の場合は、セル範囲を2次元配列として取得し、処理後に2次元配列を書き込む方法が向いています。
もちろん、VBA でも2次元配列を使うことはできますが言語仕様が貧弱です。
対して、F# は簡潔なコード記述に加えて、標準のコレクション関数が強力です。
以降、コレクション操作が頻出しますのでドキュメントへのリンクを張っておきます。
準備
サンプルとして、架空の名簿データ40人分をCSVファイルで用意しました。
Excel で開き、列幅を調整するとこのようなイメージです。
内容を確認できたら Excel 形式で保存・終了してください。
以降はファイル名を "C:\Users\user\Desktop\sample.xlsx" として進めます。
fsi で データ範囲の値を取得する
fsi に以下のコードを貼り付けると、Excel が起動してワークシートの名簿データを取得します。
罫線を引く関数などもここに置きました。
アセンブリへの参照設定(詳しくは準備編)や、入出力のフォルダ名・ファイル名は自身の環境に合わせて変更します。
#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
名前空間にあります。
open Microsoft.Office.Interop.Excel
は open System
より後に記述します。
.NET 8 にはSystem
名前空間にRange
構造体があるため、Excel のRange
とメンバー名が衝突します。
修飾のないRange
には、後に記述したMicrosoft.Office.Interop.Excel
名前空間が適用されます。
名簿データ (識別子 meibo
)について
単独セルに限らず、セル範囲に対するRange.Value
もobj
を返します。
ただし、実体は2次元配列ですので、obj[,]
へのダウンキャストが必要です。
配列のベースは Excel の既定値 [1, 1] なので、Array2D.rebase
でベースを [0, 0] にしておきます。
リベースは状況に応じて使います。
以下40番まで続きます。
meibo
の各要素に注目すると、数値はfloat
、日付はDateTime
であることがわかります。
要素の型が混在しているため、配列の型はobj[,]
である必要があります。
あらためてValue
とValue2
について
「準備編」でも触れたように、Range.Value2
の場合、日付はシリアル値(float
)となります。
Range.Value2
を使った場合を示します。生年月日がシリアル値です。
ドキュメントには次のように書いてあります。
Value2 プロパティでは、通貨型 (Currency) および日付型 (Date) のデータ型を使用しない点のみが、Value プロパティと異なります。 倍精度浮動小数点型 (Double) を使用することにより、これらの 2 種類のデータ型の値を浮動小数点数として返すことができます。
「日付型」の例は上記のとおりですが、「通貨型」についても検証結果を示します。
名簿シートの未使用セル G2 に 3000 を入力し、書式を「通貨」に設定しています。
<結論>
「通貨」として書式設定されたセルの数値は
Value
で取得した場合はdecimal
(System.Decimal
)にしかキャストできない。
Value2
の場合はfloat
(System.Double
)にしかキャストできない。
ワークシートに金銭データが含まれる場合は、取り扱いに配慮する必要があります。
実例1:座席表
以下、名簿データ(meibo
)と、過去に実務で作成したコードを記事用にアレンジして「実例」として紹介します。
最初は、名簿をもとに、縦6列で右上(教室右前方)から番号順にした座席表を作ってみます。
先ほどのmeibo
から、fsi で下図のような「座席表」シートを作成します。
実際には格子状に罫線が引かれます。
180°回転して、教壇からの目線で作った「座席表(教員用)」シートも同時に作成してみます。
次のコードを fsi に貼り付けると2枚の座席表が瞬時に作成されます。
// 名簿データ(引数 data)から2枚の座席表シートを作成する関数
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) // 横の数
// 名簿データから2次元配列を作成
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 を半回転した2次元配列(教員目線の配置)
let zaseki2D_R =
zaseki2D
|> Seq.cast<string> // 2次元配列を平坦化
|> 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
は、名簿データを引数にして2枚の座席表シートを作成します。
処理手順を簡単に説明しておきます。
机の配置
正方形を基本にして、余りが出たら縦方向に伸ばします。
たとえば40人の場合、横6人×縦7人に収めます。
生徒の配置(zaseki2D
について)
生徒の配置には、F# のコレクション関数が力を発揮します。
40人を横6人×縦7人の範囲に収める様子を示します。
次のコードはzaseki2D
を簡素化して「1行単位で実行(;;
)」します。
fsi に貼り付けると処理の様子が一目で確認できます。
これが対話環境の強みです。it
と;;
に加えて、パイプ|>
が使える分、Python の対話環境よりも使い勝手がよいと思います。
[ 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;;
言葉にすると余計にわかりにくいかもしれませんが
- 1~40の整数リストから
- 6分割して「リストのリスト」を作る
- 長さが足りないところには0を追加して、完全な6行7列を作る
- 「行」を反転する(この後、行列転置するので実質は「列」の並びを反転)
- 行列転置 (7行6列で右側の列が若い番号になる)
- 「リストのリスト」を
array2D
演算子で「2次元配列」にする
といった手順です。
教員目線の配置(zaseki2D_R
について)
zaseki2D
を180°回転させて、教員目線の配置(zaseki2D_R
)を作る手順も、zaseki2D
の手順確認に続けて実行してみます。
it |> Seq.cast<int>;;
it |> Seq.rev;;
it |> Seq.chunkBySize 6;;
it |> array2D;;
zaseki2D
の動作確認に続けた様子です。
180°回転しています。
ここまでの処理でループは使っていません。
同じ処理を VBA で記述するとしたら、かなり面倒なループを書くことになりそうです。
座席の配置を2次元配列で表現できれば、あとはセル範囲(Range
)に投げ込むだけです。
ignore
について
メソッドの中には値を返すものがあります。「メソッドの機能が目的だが返値は使わない」こともあります。
F# の関数や式を記述する際、その内部で呼び出したメソッドの返値を無視すると fsi は警告を発します。
ignore
は引数を破棄する関数ですので、不要な返値を棄てるために使用します。
COM オブジェクトは解放します
しつこいようですが、関数や式の内部でバインド(let 束縛)された COM オブジェクトは内部で解放することを忘れないようにします。
ワークシート(_Worksheet
)やセル範囲(Range
)など COM オブジェクトに関連する記述は、まとめるように心掛け、使い終わったら早めに解放します。
おまけ
zaseki2D
の頭でコメントアウトされている部分を生かすと、席順がランダムになります。
let zaseki2D: string[,] =
[ 1..num ]
|> List.randomShuffle // ランダムに配置する場合
(試してみる場合は、2枚の座席表を削除してから実行してください。)
randomShuffle
は F# 8.0.400 で追加されたものです。(2024年7月)
実例2:男女比を考慮したグループ分け
※ 実例1に続けて作業を進めます。
たとえば6班編制で清掃班を作る場合、単なる名簿順では男女の構成に偏りが生じます。
更衣室やトイレ清掃の担当時などに影響が出るので、男女比ができるだけ均等になるように男女それぞれを6分割して組み合わせてみます。
表のイメージです。(完成版はもう少し書式設定を追加しています。)
次のコードを fsi に貼り付けると「清掃班」シートが作成されます。
// 名簿データから「清掃班」シートを作成する関数
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
)の作成手順
男女比を考慮した班編制は簡易的なものです。
状況に応じて修正が必要かと思います。
班編制(group
)の式はわかりにくいので、40人を6班編成することを前提に、要所の部分だけ1行単位で実行して確認します。
次のコードを fsi に貼り付けてください。
// 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;;
実行結果です。
名簿シートや清掃班シートと照合してみると、男女を組み合わせる様子がわかると思います。
List.splitInto
関数は割り切れない場合に、リストの先頭側の要素数が大きくなります。
そのため、女性を降順にしてから分割し、末尾側の要素数が大きくなるように工夫しています。
最後に男女6グループをそれぞれ連結し(@
演算子)、班ごとに昇順ソートして完了です。
班単位の位置決めについて(groupOffsetAndNum
)
完成イメージを見ると、班の番号や班単位の罫線引きなどが必要です。
そのため、各班のセル上での位置情報を別に用意した方がよさそうです。
各班の先頭の位置(赤矢印)と人数(groupOffsetAndNum
)を求めます。
1番の生徒の位置を 0 とします。
これも、次のコードで「ステップ実行」してみます。
先ほど、班編制の作成手順確認で得られた「it
」をそのまま利用します。
次のコードを貼り付けてください。
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
)を作成する様子がわかります。
ワークシート関数の利用
F# からも Excel のワークシート関数を使うことができます。
コード中では、半角文字を全角文字に変換するためWorksheetFunction.Dbcs
を利用しています。
これは、日本語版 Excel のJIS
ワークシート関数に相当します。
ここからは寄り道です。せっかく Excel(ex
)が生きているので、例としてFrequency
ワークシート関数で「度数分布」を求めます。
(ベースが [1, 1] の2次元配列が返されます。)
([| 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] // 要素にアクセスする場合
;;
各段階の度数が得られました。
段階 | 度数 |
---|---|
~ 19 | 4 |
20 ~ 29 | 5 |
30 ~ 39 | 3 |
40 ~ | 1 |
ワークシート関数がトラブルを招くケースがあります。
次項「終了処理とスクリプト化およびゾンビプロセス対策」で触れます。
実例3:満年齢
内容的には年齢計算より、2次元配列の加工が主になります。
ワークシート関数でも普通にできることですが、F# を使って2次元配列を加工する例として載せておきます。
あらためて、名簿データを使って、「今日」の生徒?の満年齢を計算します。(データは初回投稿時のままですから、もう卒業生ですね。)
厳密な年齢計算が求められる場面では対策が必要です。
法律では「誕生日の前日に1歳加える」と解釈する場合があります。
(参考)4月1日生まれの子どもは早生まれ?
「17歳」でも投票できる?!
意外?なことに Excel でも満年齢の計算には工夫が必要です。
検索すると「隠し関数 DATEDIF
」の使用例が最も紹介されているようです。
(前世紀に人気だった Lotus 1-2-3 のサポート用らしい。当方はモッサリ動作?の Multiplan 派でしたが...)
さて、簡易的に満年齢を求めるのなら、8桁整数(yyyymmdd)の引き算で十分です。
名簿データの隣(F列)に書き込んでみます。
次のコードを fsi に貼り付けると、名簿シートに「今日」の満年齢が追加されます。
(ポイントを絞るため、人数が40人であることを前提にしています。)
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# で2次元配列をスライスする際、単一の行または列を切り出すと次元が下がります。
- (参考)配列スライスと多次元配列
Array.singleton
で2次元配列に戻したうえで行列転置します。
meibo[1.., 4]
|> Array.map (fun e -> (int $"{DateTime.Today:yyyyMMdd}" - int $"{e:yyyyMMdd}") / 10000);;
it |> Array.singleton;;
it |> Array.transpose;;
it |> array2D;;
最後には、満年齢の1次元配列が縦方向の1列の2次元配列に変換されています。
別解
行列転置にワークシート関数を利用するともっと簡潔になります。
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()
;;
こちらも動作確認をしてみます。
meibo[1.., 4]
|> Array.map (fun date -> (int $"{DateTime.Today:yyyyMMdd}" - int $"{date:yyyyMMdd}") / 10000);;
it |> ex.WorksheetFunction.Transpose :?> obj[,];;
終了処理とスクリプト化およびゾンビプロセス対策
ここで言う「ゾンビプロセス」は Excel 終了後もタスクマネージャで確認できる Excel のプロセスを指しています。
実例紹介に一区切りついたので、終了処理とスクリプト化について触れます。
また、「COM オブジェクトの解放」に関する検証と、「ワークシート関数の使用に関するトラブル」についても付け加えます。
終了時のコードは準備編とほぼ一緒で「ファイル名をつけて保存」を追加しただけです。
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# スクリプトの詳細が記載されています。
他のスクリプトの読み込みやコマンドライン引数へのアクセスなど、より便利に使うために必読です。
スクリプトファイルの実行で問題発生~とりあえずの解決法
さて、実例1~3を対話環境(fsi)へのコピペで動作確認後、上記の終了処理でゾンビプロセスは発生しませんでした。
ところが、3つのコードを一つに繋いだスクリプトファイルを走らせると、終了後も Excel のプロセスが残ってしまいます。
なぜか、dotnet fsi でゾンビが生まれますが、fsi.exe では生まれません。
スクリプト自体は正常動作するのですが、スクリプト実行のつどプロセスをkill
するのは許容できません。
現時点での対応策
検証を重ねて、とりあえず Excel のプロセスが残らない状況にたどり着きました。
答えはすでに示しています。
スクリプトであっても「終了処理の直前に;;
を入れる」ことでした。
検索してみたのですが、F# スクリプト内の;;
の働きに関する情報を見つけることができませんでした。
しかしながら、スクリプト内でも;;
が機能していることは間違いないようです。
簡単なスクリプトで検証を行い、その過程で再現できたことを簡単にまとめておきます。
- fsi.exe は、乱暴なコードでも Excel のプロセスが残らない
(極端な話、COM オブジェクトの解放やガベージコレクションを抜いても大丈夫)
(fsi.exe の終了によって解放される?) - dotnet fsi は次の3つが必須(どれかが欠けてもダメ) ---> 下記【検証1】
(1) 終了処理の直前に;;
を入れる
(2) COM オブジェクトの解放
(3) ガベージコレクションの強制 - ワークシート関数を利用する場合は要注意 --->【検証2】
obj
を返すものは要注意。キャストで回避できた。 - dotnet fsi で「中間オブジェクトを作らない(本来、心がけるべきこと)」場合はガベージコレクションを強制せずともプロセスが残らない(でも、
;;
は必要) --->【検証3】
中間オブジェクトが生じないような記述を心がけることは大切です。潜在的な問題があることを認識しつつも、個人利用の範囲で問題が無ければ利便性を優先します。
よって、本記事では dotnet fsi で、上記2番目の対応により、プロセスが残らないことだけを目標にしています。
【検証1】
追実験用として、検証したスクリプトファイルを以下に貼っておきます。
検証する場合は UTF-8 のテキストファイルとして保存してください。
ここでは、ファイル名を「検証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 に以下をコピペします。
dotnet fsi .\検証1.fsx
ps excel
sleep 5
ps excel
スクリプト終了直後は Excel のプロセスが残っていますが、5秒後(sleep 5
)には消えています。
終了処理前の;;
を抜いた場合は、5秒後でも Excel のプロセスが残っています。(ただし、10分ほど放置したら消えていました。)
【検証1】(補足)
参考までに、「終了処理直前の;;
」を抜いたコードを 対話環境(fsi)にコピペすると、fsi.exe と dotnet fsi の両者ともに同じエラーを返します。このことが、スクリプトにも影響しているのかもしれません。
【検証2】(ワークシート関数の利用時は要注意)
とりあえず、これで解決したかと思っていましたが、実例3の「別解」を走らせると再び Excel のプロセスが残ります。(※ 記事中のコードは修正済みです。)
おそらくWorksheetFunction.Transpose
が問題を起こしているようです。
COM オブジェクトかどうかは簡単に確認できます。(System.Type.IsCOMObject
)
WorksheetFunction.Transpose
は COM オブジェクトではなくobj[,]
(System.Object[,]
)です。
当然 COM オブジェクトの解放ができません。
WorksheetFunction.Transpose
はValue2
と同様にobj
を返します。(上図上部)
中身はobj[,]
ですからダウンキャストが可能です。(上図下部)
当初、「別解」ではRange.Value2
にセットするだけなので、キャストを省略していました。
そもそも、Range.Value2
はobj
を出し入れするので、WorksheetFunction.Transpose
の返値はそのままセット可能です。
とにかく怪しいのはここだけだったので、返値をobj
からobj[,]
へダウンキャストしてみたらあっさり解決しました。
まずは、ダウンキャスト(:?> obj[,]
)をコメントアウトしたスクリプトで検証します。
#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
を追加しておきました。
dotnet fsi .\検証2.fsx
ps excel
sleep 5
ps excel
kill -Name excel
キャストしたスクリプトではゾンビは生まれません。5秒後には消滅しています。
ところで、ここまで示したコード内にもワークシート関数(WorksheetFunction.Dbcs
とWorksheetFunction.Frequency
)が使われています。
まず、WorksheetFunction.Frequency
は、コード中でダウンキャストしています。
また、WorksheetFunction.Dbcs
はstring
(System.String
)を返すのでキャスト不要です。
【検証3】(中間オブジェクト作らないときはガベージコレクション不要)
参考までに、中間オブジェクトを作らないコードならばガベージコレクションを省いてもプロセスは残らないことを確認しました。
#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)
検証します。
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)
あくまでも、当方の環境で確認できた事象のまとめでしかありません。
参考程度に取り扱ってください。
コーディング環境について
お付き合いいただきありがとうございます。
F# で Excel 操作を記述する場合、コーディング環境は重要です。
多くの方が紹介しているように VSCode + Ionide for F# 拡張機能は最低限必要かと思います。
記事中の実行画面は Windows Terminal で示しましたが、実際は VSCode と 内蔵ターミナルで作業は完結します。
下図のように、コード補完も問題ありませんし、個人の開発環境としては十分すぎます。
参考
2012年頃、書店で偶然手にした「実践F# 関数型プログラミング入門」 (荒井省三、いげ太 著 技術評論社 2011年)が F# にハマるきっかけでした。
何度も読み返しましたが、12年かけて半分くらい消化できたようです。
F# を使った Excel 操作のきっかけは「Scripting Excel tests with F#」でした。
F# の実践的な学習素材として、Excel を使う手もアリかなと思った次第です。