Working with Files
Prerequisites: Sequence expressions and laziness
File access goes through .NET's System.IO. The File class covers
most needs in one call:
open System.IO
File.WriteAllText("notes.txt", "first line\nsecond line\n")
File.AppendAllText("notes.txt", "third line\n")
let content = File.ReadAllText "notes.txt"
printf "%s" content
let lines = File.ReadAllLines "notes.txt"
printfn "%d lines, last: %s" lines.Length lines[lines.Length - 1]
printfn "%b %b" (File.Exists "notes.txt") (File.Exists "missing.txt")
first line
second line
third line
3 lines, last: third line
true false
Relative paths like "notes.txt" are relative to the current
directory. In a script, __SOURCE_DIRECTORY__ gives the folder the
script lives in — use Path.Combine(__SOURCE_DIRECTORY__, "data.csv") to
find files next to it.
Processing a CSV file
open System.IO
File.WriteAllLines("sales.csv",
[ "region,product,amount"
"North,Tea,120"
"South,Coffee,340"
"North,Coffee,90"
"South,Tea,210" ])
type Sale = { Region: string; Product: string; Amount: int }
let parseLine (line: string) =
match line.Split ',' with
| [| region; product; amount |] -> Some { Region = region; Product = product; Amount = int amount }
| _ -> None
let sales =
File.ReadLines "sales.csv"
|> Seq.skip 1
|> Seq.choose parseLine
|> List.ofSeq
sales
|> List.groupBy _.Region
|> List.map (fun (region, rows) -> region, rows |> List.sumBy _.Amount)
|> List.iter (fun (region, total) -> printfn "%s: %d" region total)
let report =
sales
|> List.sortByDescending _.Amount
|> List.map (fun s -> $"{s.Product},{s.Amount}")
File.WriteAllLines("report.csv", "product,amount" :: report)
printfn "%s" (File.ReadAllText "report.csv")
North: 210
South: 550
product,amount
Coffee,340
Tea,210
Tea,120
Coffee,90
File.ReadLines is lazy — it returns a sequence that reads one line
at a time, so it works on files far bigger than memory.
File.ReadAllLines reads everything into an array at once.
Streams with use
For fine control, open a reader or writer. use closes it
automatically, even if an exception occurs:
open System.IO
do
use writer = new StreamWriter("log.txt")
writer.WriteLine "started"
writer.WriteLine "finished"
let printLines path =
use reader = new StreamReader(path: string)
let mutable line = reader.ReadLine()
while not (isNull line) do
printfn "> %s" line
line <- reader.ReadLine()
printLines "log.txt"
> started
> finished
The do block limits the writer's scope, so the file is flushed and
closed before it's read; printLines closes its reader when it returns.
(At the top level of a script, use behaves like let and F# warns, so
put use inside a function or a do block.) new is recommended when creating disposable
objects — the compiler reminds you to use them.
Directories and paths
open System.IO
Directory.CreateDirectory "data/archive" |> ignore
File.WriteAllText("data/a.txt", "A")
File.WriteAllText("data/b.log", "B")
for f in Directory.GetFiles("data", "*.txt") do
printfn "%s" (Path.GetFileName f)
printfn "%s" (Path.GetExtension "report.final.csv")
printfn "%s" (Path.GetFileNameWithoutExtension "report.final.csv")
printfn "%s" (Path.ChangeExtension("photo.jpeg", ".jpg"))
printfn "%d" (Directory.GetDirectories "data").Length
a.txt
.csv
report.final
photo.jpg
1
Path.Combine joins folder and file names with the right separator for
the operating system — never build paths by hand with "\\" or "/".
Common mistakes
- Reading a missing file throws
FileNotFoundException. CheckFile.Exists, or catch the exception and return aResult. - Forgetting
useon a stream: the file stays locked and buffered writes may never reach the disk.