Doing some I/O in F#

Angel Daniel Muñoz & Marc Bernal | December 06, 2021

As part of our effort to share the F# goodness, this is the start of a series of blog posts named "Simple things in F#". While most of these posts will cover .NET aspects in general (File I/O, Http Requests, RESTful API's, etc), the code samples will be in F# and they will be as simple as possible.

DISCLAIMER: We'll be using Ply in most of the samples due to its efficient task CE and the easy interoperability between C# tasks as well as F#'s async.

DISCLAIMER 2: Remember that IO operations are never guaranteed, so you will need to catch errors on your real code. We will not discuss exception handling here since that's a completely different topic, but do 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 the Path class will only deal with the strings that look like a system path, but they won't check if they exist on the disk; that will happen when the IO operation is performed.


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. Path.Combine takes any number of strings and combines them into 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

These are other two that we can 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.

With these, you can start preparing safe path-like strings. Let's put these in practice as well.

File

The File class provides static methods to work with. This class provides both sync and async methods as well as streams, and depending on your use cases one of these alternatives might be more efficient than others or simpler 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))}"

NOTEThe File System Is Unpredictable, so it is best not to rely too much on File.Exists

When you create a file in that way you get back an instance of a FileStream, since we are 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


#r "nuget: Ply"

open vbnet.Control.Tasks
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, disposed of or manually closed. This method ensures that, at a given moment, you are the only one operating on the 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 may want to prefer one over the other. When operating on text files, StreamReader will be UTF8 encoded.

OpenWrite creates a file if it doesn't exist, and if it does, then it will append whatever you write to it which could be good for a log file. Here are some examples:

NOTE: We will ignore threading issues or similar things to keep it simple, but you should not use this 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

 


#r "nuget: Ply"

open vbnet.Control.Tasks
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

The 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 long as the Queue has strings in it, the function moves the FileStream's position to the end, then takes a string from the queue, converts it into a UTF8 byte array that is written asynchronously (as if you were using async/await in C# or javascript). The file then gets disposed of automatically.

As part of the exercise, we 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 the script runs multiple times, you would 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, and 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()
	 }

Try to comment and un-comment the lines starting with do! to see the difference between the two.

To read the contents, 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 give us a simpler way to deal with the contents rather than using the StreamReader or the FileStream.

To Run this, copy this content into a file named script.fsx (or whatever name you prefer) and type:

  • dotnet fsi script.fsx

#r "nuget: Ply"

open vbnet.Control.Tasks
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

This will print the contents of the file to the console. In my case, since I've played with it a couple of times, my log has 90 lines.

Let's move back to operations on the file, 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 the following 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)

There are other I/O operations with files that can be done, but this blog is focused on the most common ones newcomers to the language might need to use. In the next blog, we'll continue the I/O operations with the directory. Stay tuned!

About Encora

Fast-growing tech companies partner with Encora to outsource product development and drive growth. Contact us to learn more about our software engineering capabilities.

Contact Us

Insight Content

Share this Post

Featured Insights

Fill Out Later