(2024.8.31 更新)
個人メモ程度の記事ですが、現在でもポツポツと反応をいただいています。
F# を取り巻く環境も変わってきたということで、現状に合わせて手を入れさせていただきました。
はじめに
高校で非常勤講師をしております。
当然、学校でもあらゆる場面で Excel が利用されていますが、定型作業が大半です。
個人的に Excel の定型作業で F# インタラクティブ(以下 fsi)を重用しているので記事にしてみました。
fsi の詳細はこちらです。
まずは、VBA でマクロを書いた経験のある方、そして、F# に関心のある方を対象に、fsi の導入から単独セルの読み書きまでを準備編としてまとめました。
(実例は実例編で)
本稿は「覚え書き」であり、個人的な試行錯誤の結果をまとめたものです。
不正確な表記や誤りもあるかと思います。
また、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 を探します。
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 のフルパスになります。
このフルパスで起動してみます。(起動が確認できたらショートカットやバッチファイルを作ります。)
& "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\FSharp\Tools\fsi.exe"
起動メッセージとプロンプトが出たら、.NET のバージョンを確認してみます。
System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription;;
val it: string = ".NET Framework 4.8.9261.0"
.NET Framework 4.8 環境であることがわかります。
なお、fsi ではセミコロン2つ「;;
」が実行の指示です。
以下、「;;
」の有無も意識してお読みください。
fsi の終了は
#q;; // #quit
です。一旦終了させておきます。
【画面1】 fsi.exe のフルパス確認~起動 → .NET のバージョン確認 → fsi の終了
(2) .NET 8 SDKをインストールした場合
コマンドラインからdotnet fsi
で起動します。
こちらは .NET 8 環境になります。
【画面2】 dotnet fsi による起動 → .NET のバージョン確認 → fsi の終了
(1) と (2) の違いは最後に補足しておきますが、以下の作業ではどちらの環境でも問題ありません。本稿は両環境で動作確認しました。
fsi から Excel を起動する
初回投稿時から状況が変わり、参照設定できない場合がありました。
確実な方法として、アセンブリをフルパスで参照設定するように変更しました。
アセンブリの確認
Excel 起動前に必要なアセンブリ(次の2つ)を探します。
- Microsoft.Office.Interop.Excel.dll
- office.dll
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
で、先ほど見つけたアセンブリへの参照を設定するので、こちらは個々の環境に合わせてください。パス直前の@
を忘れると\
がエスケープ文字と解釈されて失敗します。
#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
のように型を明記できます。
型を省略した場合は型推論されますが、トラブル回避のため明記した方がよいでしょう。
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 内部のオブジェクトを取得するとobj
型を返す場合があります。
そのときはdowncast
演算子で本来の型にキャストする必要があります。
キャストに関しては後半でも触れます。
単独セルの操作
まず、セル操作の前に Sheet1 へ適当なデータを入力します。
下表の範囲をコピーして、fsi で起動した Excel のセル A1 に [形式を選択して貼り付け - テキスト] でデータを入れます。
番号 | 姓 | 読み |
---|---|---|
1 | 阿部 | アベ |
2 | 伊藤 | イトウ |
3 | 植田 | ウエダ |
4 | 遠藤 | エンドウ |
5 | 大石 | オオイシ |
Range
とCells
最初に VBA でおなじみのRange
とCells
について確認します。
セル B2 をそれぞれの方法で let 束縛してみますが、あえて、型を明記しないでおきます。
let cell1 = ws.Range("B2")
let cell2 = ws.Cells[2, 2]
;;
val cell1: Range
val cell2: obj
cell1
の型はRange
です。一方、cell2
はobj
です。
cell2
の右辺 ws.Cells[2, 2]
はws.Cells.Item(2, 2)
の省略形です。
Cells
プロパティは全セル範囲をRange
で返しますが、その先の Item
プロパティがobj
を返します。
Cells[row, col]
だけではセルの値を取得できません。
VBA でValue
の省略に慣れている方はご注意ください。
それぞれについてValue
プロパティで値を見てみます。
Value
プロパティは引数をとるので、引数は省略できても( )は省略できません。Value()
とします。
cell1.Value();;
val it: obj = "阿部"
cell1
から文字列を取得できましたが、型はobj
ということに注意してください。
let 束縛のない式を実行すると、暗黙的にit
という識別子で束縛されます。
新たに書き換えられない限りit
を利用することができます。
cell2
のValue
プロパティを確認します。
cell2.Value();;
error FS0039: 型 'Object' は、フィールド、コンストラクター、またはメンバー 'Value' を定義していません。
cell2
はobj
なので、Value
プロパティが使えません。
前述のようにRange
にダウンキャストする必要があります。
キャストを考慮した例です。
let cell3: Range = downcast ws.Cells[2, 2]
let cell4: Range = ws.Cells.Resize(1, 1).Offset(1, 1) // キャスト不要
;;
val cell3: Range
val cell4: Range
cell3
はobj
からRange
にキャストされています。
cell4
はResize
, Offset
プロパティを使ってセル B2 を指しています。
これらのプロパティはRange
を返してくれるのでキャストが不要です。
単独セルに限らず、セル範囲を数値で指示したい場合に重宝します。
Cells
(シート上の全セル範囲)に対して、リサイズ前にオフセットするとセル範囲がオーバーフローするのでResize
を先にします。
let cell4 = ws.Cells.Offset(1, 1).Resize(1, 1);; // Resize を前に置くこと!
値の取得
セルの値はValue
やValue2
プロパティなどで取得します。
Value2
が簡便ですが、日付データの取得にはValue
プロパティが適切です。
(詳しくは実例編で)
Value
やValue2
はobj
を返します。
// キャストしない場合は obj を返す
let no1 = ws.Range("A2").Value()
let sei1 = ws.Range("B2").Value()
let yomi1 = ws.Range("C2").Value()
;;
val no1: obj = 1.0
val sei1: obj = "阿部"
val yomi1: obj = "アベ"
obj
型はfloat
(int
は不可)やstring
にキャストしないと数値や文字列としての処理ができません。
挿入文字列への挿入や、Object.ToString
で文字列を作る場合などはキャスト不要です。(後述)
通常はキャストしましょう。
let no1: float = downcast ws.Range("A2").Value()
let sei1: string = downcast ws.Range("B2").Value()
let yomi1: string = downcast ws.Range("C2").Value()
;;
キャストには:?>
演算子を使う方法もあります。こちらが少し簡潔です。
// :?> 演算子を使う方法
let no1 = ws.Range("A2").Value() :?> float
let sei1 = ws.Range("B2").Value() :?> string
let yomi1 = ws.Range("C2").Value() :?> string
;;
どちらも結果は同じです・
val no1: float = 1.0
val sei1: string = "阿部"
val yomi1: string = "アベ"
詳細は以下を参照してください。
おまけ
Text
プロパティは数値も文字列として返します。(要キャスト)
他にObject.ToString
による文字列の取得、'obj'型から .NET API で型変換もできます。
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
;;
val a3_text: string = "2"
val a3_tostr: string = "2"
val a3_int: int = 2
値の書き込み
値の用意
とりあえず、先ほど取得したデータを加工して、書き込み用に文字列を作ります。
ここでは、挿入文字列を使ってみましょう。
let kojin1 = $"{no1,2} {sei1}({yomi1})";;
val kojin1: string = " 1 阿部(アベ)"
結合した文字列kojin1
ができました。
挿入文字列に埋め込む値はobj
でも構いません。
値を書き込む
fsi で上書きされたセルは Excel で元に戻せません。
値の書き込みには細心の注意を払ってください。
セルへの書き込みはValue2
プロパティの使用が簡潔です。
Value2
プロパティを使って、文字列kojin1
をセル E2 に書き込んでみます。
F# はプロパティのセットに<-
を使います。
ws.Range("E2").Value2 <- kojin1;;
Excel のセル E2 に文字列が表示されるはずです。
Value2
でセットされた文字列や数値は Excel 側で解釈してくれます。
たとえば、文字列 "20240101" は数値、 "2024/01/01" は日付として解釈されます。
Value
プロパティを使う場合
Value
による書き込みは引数を省略できません。
文字列kojin1
をセル G2 に書き込んでみます。
ws.Range("G2").Value() <- kojin1;; // Value() は引数が必要
error FS0193: 内部エラー: 入力は負以外である必要があります。 (Parameter 'n')
引数を渡せば動作します。
ws.Range("G2").Value(XlRangeValueDataType.xlRangeValueDefault) <- kojin1;;
または
ws.Range("G2").Value(null) <- kojin1;;
Value
プロパティは文字列や数値の他にSystem.DateTime
型なども渡すことができます。
Cells[,]
プロパティも使えます
セル I2 (2行9列)に書き込んでみます。
ws.Cells[2, 9] <- kojin1;;
単独セルの値をコピーするだけなら、VBA 的な書き方が可能です。
ws.Cells[1, 5] <- ws.Cells[1, 1];;
for ループで連続コピーも可能です。
for row in 2..6 do
ws.Cells[row, 5] <- ws.Cells[row, 1]
;;
一旦、後始末しましょう。
お疲れさまでした。
ここまでは F# を使うメリットが見えませんが、実例編では VBA では得られない F# の強力さと爽快感を書きます。
では、重要な後始末です。
いくら注意していても「COMオブジェクトの解放漏れ」をやらかします。
この件に関してはネット上でも長年議論が続いてますし、説明もできません。
ということで、次の記事などを参考にしてアレンジしています。
これらの記事で指摘されている「中間オブジェクト」(暗黙的に作成されるオブジェクト)の件も承知していますが、本稿では見なかったことにしています。
本稿中に隠れている「中間オブジェクト」の例
ex.Workbooks.Add()
--- 途中にWorkbooks
ができる
wb.Worksheets[1]
--- 途中にSheets
ができる
ws.Cells[2, 2]
--- 途中にRange
ができる
ws.Range("A2").Value()
--- 途中にRange
ができる
大半のコードで「中間オブジェクト」が生じています。
以下のコードで終了時の処理を行います。
;;
// ↑ 終了処理の前に式や関数を評価しておくこと
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()
;;
これまでの経験で注意してきた点を記します。
● 終了処理の前に;;
を置く
詳しくは実例編に書きますが、fsi を使った処理に慣れてくると、終了処理までのコードを一気に流してしまうようケースも出てきます。
上の例のように終了処理前に;;
を置き、式や関数の評価を済ませておかないと、環境にもよりますが、隠れ Excel プロセスが発生します。
● fsi で起動した Excel は、fsi で終了させる
もし、自分で Excel を閉じてしまった場合は、下記コードからwb.Close()
, ex.Quit()
を抜くことになりますが、プロセスが残る可能性は大きくなります。
● let 束縛した Excel のオブジェクトをこまめに解放(Marshal.ReleaseComObject
)する。
できれば、作業の途中でも COM オブジェクトを使い終わった段階で解放してください。
未解放の識別子を使い回し(上書き)しないでください
COM オブジェクトが生き埋めになります。
特にループ内は要注意です。
fsi の場合、COM オブジェクトがit
で返ってきた場合は、すぐにit
を解放してください。
例えば、セル A1 の内容を確認しようとして、うっかり.Value2
を忘れると・・・
ws.Range("A1");;
長いシーケンスが返ってきます...
val it : Range =
seq
[seq
[seq
[seq
(以下省略)
画面が埋め尽くされてしまいますが、スクロールアップして確認するとit
はRange
オブジェクトです。
すかさず
Marshal.ReleaseComObject(it);;
で、解放してください。
未解放のまま新たなit
が作成されると「生き埋め」状態になります。
さて、先ほど実行したcell1.Value()
で返されたit
の場合は?
val it : obj = "阿部"
このit
もobj
ですが、中身は COM オブジェクトではなくstring
なので解放不要です。
先ほど作成したcell2
もobj
ですが、こちらは中身が COM オブジェクトRange
なので解放しました。
後始末を忘れて fsi を閉じたり、オブジェクト解放を怠ると Excel を終了してもプロセスだけが残り、メモリを消費します。
プロセスが残っていないか確認しましょう
Excel が全て閉じられていても次の手順で確認しましょう。
ps excel
Get-Process: Cannot find a process with the name "excel". Verify the process name and call the cmdlet again.
NPM(K) PM(M) WS(M) CPU(s) Id SI ProcessName
------ ----- ----- ------ -- -- -----------
48 65.55 115.77 2.25 5880 7 EXCEL
失敗した場合は以下のコマンドで Excel のゾンビを退治します。
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 のドキュメントを確認しておきましょう。
以下、「学校業務でありそうな作業」を例にした「実例編」に続きます。