Angel Daniel Munoz Gonzalez
Posted on March 12, 2021
Simple things in FSharp
As part of my effort to share the F# goodness I'll start a series of blog posts named "Simple things in F#" while most of these posts will cover .NET aspects in general (File IO, Http Requests, RESTful API's, etc) the code samples will be in F# and they will be as simple as possible.
DISCLAIMER: Remember that IO operations are never guaranteed so you will need to catch errors on your real code I will not discuss exception handling here since that's a complete different topic but keep it in mind please
Let's begin this series with some simple IO.
the .NET BCL (Base Class Library) has a bunch of useful classes that allow us to do things in a "standard" way in this case we'll be using the System.IO namespace.
Path
The Path class has some cool methods that allow us to manipulate the path to files and directories in a safe, cross-platform manner it's worth mentioning that Path
class will only deal with the strings that look like a system path, but they won't check if they exist in the disk that will happen when you do the IO operation.
open System.IO
/// this will get the deepest directory name in a path like string
let dirname = Path.GetDirectoryName("./scripts/script.fsx")
// dirname = @".\scripts" // on windows
// dirname = @"./scripts" // on unix like systems
let ext =
Path.GetExtension("./scripts/script.fsx")
let filename = Path.GetFileName("./scripts/script.fsx")
let filenamenoext =
Path.GetFileNameWithoutExtension("./scripts/script.fsx")
let fullpath = Path.GetFullPath("./scripts/script.fsx")
printfn $"{ext}, {filename}, {filenamenoext}\n{fullpath}"
// .fsx, script.fsx, script
// c:\Users\scyth\repos\blogpostdrafts\scripts\scripts\script.fsx
these are just a few samples the one I use the most is Path.Combine
which takes any amount of strings and combines them in a single path like string. e.g.
open System
open System.IO
let pathlike =
Path.Combine(@"..\", $"{Guid.NewGuid()}", $"{Guid.NewGuid()}", "finalfile.fsx")
printfn "%s" pathlike
// ..\19cc7bf9-ba2e-4cb9-ba00-aafc80b71dce\059b0260-501c-4be8-ad99-22625f2d9a7a\finalfile.fsx
// of course your guids will vary
Also, there other two that I want to mention as well
Path.GetInvalidFileNameChars()
Path.GetInvalidPathChars()
before creating a file/path perhaps you want to check that you don't have disallowed characters and get an exception ahead.
That being said, with these you can start preparing safe path like strings let's put these in practice as well.
File
The File class, it is a class that provide static methods to work with... yes, Files... this class provides both sync and async methods as well as streams, depending on your use cases each of the alternatives might be more efficient than others or more simple to work with.
Let's start by creating and deleting a file first
// let's build a path from the system's temp path and a "Sample.txt" filename
let path =
Path.Combine(Path.GetTempPath(), "Sample.txt")
printfn $"Does the file exists yet? {File.Exists(path)}"
let file = File.Create(path)
// let's close the file and check the name
file.Close()
printfn $"{file.Name}"
// and delete it afterwards
File.Delete(path)
// we can check if we did delete it
printfn $"Did we delete the file? {not (File.Exists(path))}"
NOTE: The File System Is Unpredictable so don't rely too much on
File.Exists
π
when you create a file in that way you get back an instance of a FileStream
since I'm using the fsi interpreter it will not allow me to use use
but remember that streams are disposable so you can use them this way as well
open System
open System.IO
let createSampleFile() =
task {
let path =
Path.Combine(Path.GetTempPath(), "Sample.txt")
/// notice ***use*** instead of ***let***
use file = File.Create(path)
let bytes = System.Text.Encoding.UTF8.GetBytes("Sample content")
do! file.WriteAsync (ReadOnlyMemory bytes)
// after the file gets out of the scope
// the resources will be disposed automatically
}
To open an existing file we can use the following methods
- File.Open
- File.OpenRead
- File.OpenText
- File.OpenWrite
the Open
method will lock the file until its closed either disposing it or manually closing it which is the one you may want when you want to ensure only you at a given moment is operating on that file.
OpenRead
, OpenWrite
, and Open
will give you a FileStream
instance while OpenText
will give you a StreamReader
they are all streams but depending on the operation you are performing perhaps you would want to prefer one thing above the other if you know you are operating on text files well, that's what OpenText
is for since the StreamReader
will be in UTF8 Encoding.
OpenWrite
Will create a file if it doesn't exist, if it does then it will append whatever you write to it which could be good for a log file let's see a brief sample
NOTE: We will ignore threading issues or similar things to keep it simple but you should not use this naΓ―ve approach to logging
To Run this, copy this content into a file named
script.fsx
(or whatever name you prefer) and type:
dotnet fsi script.fsx
open System
open System.Collections.Generic
open System.IO
open System.Text
let queue = new Queue<string>()
let log (logValue: string) = queue.Enqueue logValue
/// naive approach to logging
let flushToLog () =
task {
let path = Path.Combine("./", "sample.log")
/// note the use of ***use*** and not ***let***
/// since this is a disposable stream
/// we use the automatic disposing mechanisms in .NET
use file = File.OpenWrite(path)
while queue.Count > 0 do
// let's move the stream's position to the end so we don't overwrite what was previously written
file.Position <- file.Length
let line = queue.Dequeue()
printfn "%s" line
let bytes = Encoding.UTF8.GetBytes $"%s{line}\n"
do! file.WriteAsync(ReadOnlyMemory bytes)
}
task {
for i in 1 .. 10 do
log $"Logging: %i{i}"
do! flushToLog ()
for i in 11 .. 20 do
log $"Logging: %i{i}"
do! flushToLog ()
for i in 21 .. 30 do
log $"Logging: %i{i}"
do! flushToLog ()
}
|> Async.AwaitTask
// we need to run this synchronously
// so the fsi can finish executing the tasks
|> Async.RunSynchronously
that script defines a log
function that puts a string in a string Queue
.
It also defines a function flushToLog
which when called opens a file called sample.log and as log as the Queue
has strings on it, it moves the FileStream
's position to the end, it takes a string from the queue converts it into a UTF8 byte array
which writes asynchronously (as if you were using async/await in C# or javascript) then the file moves out of scope and gets disposed automatically
as part of the exercise we then write 31 lines (including the last line break) to the file in three batches, you may notice that if the file doesn't exist it gets created the first time, if you run that script multiple times you will have 31 lines repeated over and over again without deleting the previous content.
Keep in mind that there are simpler API's that can be used if you are not going to do complex operations in a file, instead of managing a FileStream
you could use File.WriteAllLinesAsync
or File.AppendAllLinesAsync
, replace the flushToLog
function with the following
let flushToLog () =
task {
let path = Path.Combine("./", "sample.log")
// do! File.WriteAllLinesAsync(path, queue)
do! File.AppendAllLinesAsync(path, queue)
queue.Clear()
}
comment and un-comment the lines starting with do!
to see the difference between each other π
to read the content's we can use the following API's and their async counterparts
- File.ReadAllLines - returns an array of strings
- File.ReadAllText - returns the whole content as a single string
these will give us a simpler way to deal with the contents rather than using the StreamReader or the FileStream and manually read bytes and move the position of the stream
To Run this, copy this content into a file named
script.fsx
(or whatever name you prefer) and type:
dotnet fsi script.fsx
open System
open System.IO
task {
let path = Path.Combine("./", "sample.log")
let! lines = File.ReadAllLinesAsync path
let! content = File.ReadAllTextAsync path
printfn $"Content:\n\n{content}"
printfn $"Lines in file: %i{lines.Length}"
}
|> Async.AwaitTask
// we need to run this synchronously
// so the fsi can finish executing the tasks
|> Async.RunSynchronously
that will print the content of the file to the console and the lines in the file, in my case since I've played with it a couple of times it told me my log has 90 lines
Let's move back to operations on the file as a whole since we now kind of know how to open/read/write/delete a file what about copying and moving files around?
To Run this, copy this content into a file named
script.fsx
(or whatever name you prefer) and type:
dotnet fsi script.fsx
open System
open System.IO
let path = Path.Combine("./", "sample.log")
let filename = Path.ChangeExtension(path, "txt")
File.Copy(path, filename)
We used the Copy
method this time plus a nice utility of the Path
class, after running that you should have a New file with the name sample.txt
To Run this, copy this content into a file named
script.fsx
(or whatever name you prefer) and type:
dotnet fsi script.fsx
open System
open System.IO
let source = Path.Combine("./", "sample.txt")
let destiny =
// assuming that the directory "samples" exists
Path.Combine("./", "samples", "moved.txt")
File.Move(source, destiny)
So, that's it there are other IO operations with files that can be done, but I refrained myself to the most common ones or the ones I feel newcomers to the language might feel lost in the next post I'll continue the IO posts with the Directory so stay tuned!
As always feel free to ping me on twitter or in the comments below π
Update:
from F# 6.0 .NET6 Ply
is not needed anymore, task {}
has been incorporated into the F# Core Library
Posted on March 12, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.