Kotlin Training Program

DOWNLOAD APP

FEEDBACK

File I/O

File and its need

File is a computer resource / object for storing variety of data including text, image, video, etc.

We can easily store data in variables, then why do we need a file? Files are required to persist data i.e. sustain data even when computer is shutdown. The data stored in variables stay in memory only during the program execution, thereafter it is cleared i.e. it is temporary. To permanently store data, we need Files.

A computer has two types of memory :

The programs that we write are saved in .kt files on the secondary memory. When we run the program, program and its required data is brought into primary memory for execution. All variables declared and used in the program are also saved in primary memory. Once the program execution is complete, all its data in Primary memory is cleared. If we want to save the data such that it survives not only program completion but computer shutdown also, then we have save in files on the secondary memory.

Example - suppose we are writing a Word document and it is not yet saved in a file. If we try closing the window, applicat warns us that progress will be lost if not saved. This is because while we write the document, it is saved in primary memory. If we do not save but close the application, then data will be lost. Only when we save it in a file on secondary memory, we will be able to reopen it after closing the application or even after computer restarts.

Problem statement

Suppose we have to develop an Expense Manager Application that would allow user to :

All expenses need not be added at once. User should be able to do above tasks even after restarting the application. All data added previously must be retained.

For the above problem statement, we can not use List to persist the expenses data. Yes, it is required for modifications but data will be lost when program restarts. To prevent data loss, we have to save the data in a file. Let us now learn the basics of reading and writing files to implement this solution.

Basics

Creating file object

To deal with a file, we have to first create an instance of the File class (part of Kotlin File API). The absolute / complete path of file must be passed as Constructor argument :

 File(/* path: String */)
 

Example :

 // Points to the file "sample.txt"
val file = File("A:\\Documents\\sample.txt")
 

Note that each backslash is escaped with an extra backslash \ → \\.

Resources folder

When working on a project, IntelliJ provides a resources folder where we can save non-code files like text files, images, etc. We can point to a file in resources folder using the path $projectPath\\src\\main\\resources\\$fileName. Example :

 val file = File("A:\\Projects\\KTP\\src\\main\\resources\\sample.txt")
 

To avoid writing the project path, we can use the Path class :

 Path("src\\main\\resources", /* fileName */).absolute().toFile()
 

Example :

 val file = Path("src\\main\\resources", "sample.txt").absolute().toFile()
 

We can write a utility function getResFile() to create an instance of a resource file :

 fun getResFile(fileName: String): File {
    return Path("src\\main\\resources", fileName).absolute().toFile()
}
 

Usage :

 val file = getResFile("sample.txt")
 

Writing string to file

To write a string to file, we use the writeText() function :

 /* file */.writeText(/* textToWrite */)
 

New file will be created if it does not exist or overwritten if it already exists at the given path.

Example :

 val file = getResFile("output\\text\\sample.txt")
file.writeText("Hello Files!")
 

Note that writeText() function creates only the file and not folders (directories) in its path, if it does not exist. In the above example, only the file sample.txt will be created. Directories output and text will not be created if they don’t exist. If directories do not exist, a FileNotFoundException will be thrown. A solution to this limitation will be explained under Directories chapter.

Append Text

When file already exists, instead of overwriting its contents if you want to append text, use the appendText() function :

 /* file */.appendText(/* textToAppend */)
 

Similar to writeText() function, appendText() function also creates a new file if it does not exist (but not intermediate directories).

Example :

 file.appendText("\nFile I/O is easy in Kotlin!")
 

Reading string from file

To read the entire file contents as string, use the readText() function :

 /* file */.readText()
 

Example :

 val text = file.readText()
 

Read Lines

Rather than reading entire file as a single string, you can read line by line using the forEachLine() function :

 /* file */.forEachLine { line ->
		// Use line
}
 

Example :

 val file = getResFile("sample.txt")

var i = 1
file.forEachLine { line ->
		println("L#${i++} : $line")
}
 

To read all lines in a List<String>, use the readLines() function :

 /* file */.readLines()
 

Example :

 val file = getResFile("sample.txt")
val lines = file.readLines()
 

File PrintWriter

Recall how we write to Standard I/O using the print() and println() functions. Similarly, we can write to files too. For this, we use the PrintWriter class. To get the PrintWriter object for a file, invoke its printWriter() function :

 /* file */.printWriter()
 

Thereafter we can invoke print() and println() functions of PrintWriter object to write to the file. At last, flush() function needs to be called to commit the changes to the file.

Example :

 val file = getResFile("sample.txt")

file.printWriter().run {

    // Print anything to the file
    println("File I/O using PrintWriter")
    print("Pi = ")
    print(22/7f)

    // Flush at last
    flush()
}
 

File Scanner

Recall how we read data from Standard I/O using functions like nextLn(), nextInt() etc. Similarly, we can read from files too. To do so, we need Scanner object of the corresponding file. For this, invoke the Scanner constructor by passing file.inputStream() to it :

 Scanner(/* file */.inputStream())
 

Thereafter we can use the functions like nextLn(), nextInt() etc. on it.

Example :

 val file = getResFile("sample.txt")

// Reads ints from the file and print their sum
val scanner = Scanner(file.inputStream())
scanner.run {
    var sum = 0
    while (hasNextInt()) {
        val num = nextInt()
        println("adding $num...")
        sum += num
    }
    println("sum = $sum")
}

/* Contents of sample.txt :

123
456
789
 */

/* Output :

adding 123...
adding 456...
adding 789...
sum = 1368 
 */
 

Working with objects

So far we have seen how to write and read primitive data types like Int, Float, Char, String etc. Let us now see how to write and read objects.

Writing using toString()

Recall that when printing an object, it is first converted to a String using toString() function and then printed onto the console. Similarly, we can directly write an object to file, wherein its toString() function will be called implicitly.

Example :

 val file = getResFile("sample.txt")
val person = Person("Alpha", 30)
    
// Way 1 : Using PrintWriter
file.printWriter().run {
    print(person)
    flush()
}
    
// Way 2 : Using writeText()
// file.writeText(person.toString())

/* Contents of sample.txt after run :
Person(name=Alpha, age=30)
 */
 

Note that Person is a Data class, so toString() function nicely formats all properties of Person class to a String.

This approach works only one way i.e. for converting object to string but not reconstructing object from string. Once we write object as string to a file, we can not read it as object again.

 val file = getResFile("sample.txt")
val personString = file.readText()
// val person = ?? <- Won't work
 

To convert object to String in a such a way that reconstructing object from String is also possible, we have to use Serialization and Deserialization technique.

Serialization

Serialization refers to the process of converting data into a primitive form for the purpose of transmission or saving to a file.

Using Serialization, we first convert object to string of some structured format like JSON, ProtoBuf, XML etc. Here we will use JSON format because it is concise and widely used. Then, the string is written to the file.

 Serialization : Object -> JSON String

// Example :
Person(name="Alpha", age=30) -> {"name":"Alpha","age":30}
 

Deserialization refers to the process of converting data of primitive form back to structured format for the purpose of receiving transmission or reading a file.

When reading the file, JSON string is converted back to object using Deserialization.

 Deserialization : JSON String -> Object

// Example :
{"name":"Alpha","age":30} -> Person(name="Alpha", age=30)
 

Using Kotlin library

Kotlin Serialization support is not a part of StdLib. To use it in our project, we have to add the following Gradle dependency :

 // Plugin
kotlin("plugin.serialization") version kotlinVersion

// Dependency
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$version")
 

Annotate

To add serialization support to a particular class, annotate it as @Serializable :

 @Serializable
class /* className */
 

Example :

 @Serializable
data class Person(
    val name: String,
    val age: Int
)
 

Encode

To serialize an object of Serializable class to string, use the Json.encodeToString() function :

 Json.encodeToString(/* object */)
 

Example :

 val file = getResFile("sample.txt")
val person = Person("Alpha", 30)
val personString = Json.encodeToString(person)
file.writeText(personString)
 

Decode

To deserialize encoded JSON string back to object, use the Json.decodeFromString() function :

 Json.decodeFromString</* Class */>(/* jsonString */)
 

Example :

 val file = getResFile("sample.txt")
val personString = file.readText()
val person = Json.decodeFromString<Person>(file.readText())
println(person)
 

Working with List

Similarly, we can serialize and deserialize List of objects :

 val persons = listOf(
    Person("Alpha", 30),
    Person("Beta", 20),
    Person("Gamma", 10)
)

// Serialization of List<Person>
val personsString = Json.encodeToString(persons)

val file = getResFile("sample.txt")
file.writeText(personsString)
 
 val file = getResFile("sample.txt")

val personsString = file.readText()

// Deserialization of List<Person>
val persons = Json.decodeFromString<List<Person>>(personsString)

println(persons)
 

Working with Map

Same is the case for Map involving Serializable class :

 val nameToPersonMap = listOf(
    Person("Alpha", 30),
    Person("Beta", 20),
    Person("Gamma", 10)
).associateBy { it.name }

// Serialization of Map<String, Person>
val mapString = Json.encodeToString(nameToPersonMap)

val file = getResFile("sample.txt")
file.writeText(mapString)
 
 val file = getResFile("sample.txt")
val mapString = file.readText()

// Deserialization of Map<String, Person>
val nameToPersonMap = Json.decodeFromString<Map<String, Person>>(mapString)

println(nameToPersonMap)
 

Drawback

A minor drawback of this library is that it requires us to annotate the classes to serialize as @Serializable. This is not required when using Gson library.

Using Gson library

Gson is a Serialization library by Google which uses JSON format.

Setup

To use Gson, add the following Gradle dependency :

 implementation("com.google.code.gson:gson:$version")
 

Before serialization or deserialization, create an instance of Gson class :

 val gson = Gson()
 

Serialization

To serialize an object to JSON string, use Gson#toJson() function :

 /* gson */.toJson(/* object */)
 

Example :

 val file = getResFile("sample.txt")
val person = Person("Alpha", 30)
val personString = Gson().toJson(person)
file.writeText(personString)
 

Deserialization

To deserialize object from JSON string, use the Gson#fromJson() function :

 /* gson */.fromJson(/* jsonString */, /* Class */::class.java))
 

Example :

 val file = getResFile("sample.txt")
val personString = file.readText()
val person = Gson().fromJson(personString, Person::class.java)
println(person)
 

Working with List

Similarly, we can serialize List of objects :

 val persons = listOf(
    Person("Alpha", 30),
    Person("Beta", 20),
    Person("Gamma", 10)
)
// Serialization of List<Person>
val personsString = Gson().toJson(persons)
val file = getResFile("sample.txt")
file.writeText(personsString)
 

Deserializing list of objects is somewhat different. We can not pass type as List to fromJson() function.

 // Following won't work
val persons = Gson().fromJson(personsString, List<Person>::class.jav)
 

An instance of TypeToken class is required to specify type as List :

 fromJson</* type */>(jsonString, object: TypeToken</* type */>() {}.type)
 

Example :

 val file = getResFile("sample.txt")
val personsString = file.readText()

// Deserialization of List<Person>
val type = object : TypeToken<List<Person>>() {}.type
val persons = Gson().fromJson<List<Person>>(personsString, type)

println(persons)
 

Notice that type has to specified twice. We can define a utility function make this code more concise :

 inline fun <reified T> Gson.fromJson(json: String) = 
    fromJson<T>(json, object: TypeToken<T>() {}.type)
 

Usage :

 val persons = Gson().fromJson<List<Person>>(personsString)
 

Working with Map

Same is the case for Map involving Serializable class :

 val nameToPersonMap = listOf(
    Person("Alpha", 30),
    Person("Beta", 20),
    Person("Gamma", 10)
).associateBy { it.name }

// Serialization of Map<String, Person>
val mapString = Gson().toJson(nameToPersonMap)

val file = getResFile("sample.txt")
file.writeText(mapString)
 
 val file = getResFile("sample.txt")
val mapString = file.readText()

// Deserialization of Map<String, Person>
val nameToPersonMap = Gson().fromJson<Map<String, Person>>(mapString)

println(nameToPersonMap)
 

Notice that Gson library does not require us to annotate classes before serialization.

Directories

Directory / Folder is a special type of file that can hold multiple other files.

To work with directory, we can use the same File class. For creating a File instance that points to a directory, pass the directory path to File constructor :

 File(/* directory path */)
 

Example :

 // Points to the "output" folder in resources
val dir = File("A:\\Projects\\KTP\\src\\main\\resources\\output")
 

Creating a directory

To create an empty directory pointed by File object, invoke the mkDir() function :

 fun File.mkDir(): Boolean
// returns a Boolean whether the directory was created
 

Example :

 val dir = getResFile("output")

if (dir.mkDir()) {
		println("Directory created!")
} else {
		println("Directory already exists OR couldn't create")
}
 

mkDir() function creates only the directory at given path and not any intermediate directories. For example - if path A\B\C is passed, then only directory C will be created. Directories A & B won’t be created if do not exist, as a result of which C won’t be created either. To create intermediate directories, use mkdirs() function.

Creating directories

To create multiple non-existent directories in a given directory path, use the mkDirs() function :

 fun File.mkDirs(): Boolean
// returns a Boolean whether the directories were created
 

Example :

 val dir = getResFile("output\\pdfs")

if (dir.mkDirs()) {
		println("Directories created!")
} else {
		println("Directories already exist OR couldn't create")
}
 

mkDirs() function creates intermediate non-existent directories also. In the above example, output as well as pdfs, both folders will be created if non-existent while mkDir() would create only the pdfs folder.

Exists Check

To check whether a file or directory pointed by a File object exists, use the exists() function :

 fun File.exists(): Boolean
 

Example :

 val file = getResFile("sample.txt")

if (file.exists()) {
		println("File exists!")
} else {
		println("File does not exist!")
}
 

Directory check

To check whether a file is a directory or a normal file, we use the isDirectory property :

 /* file */.isDirectory
 

Example :

 val file = getResFile("sample.txt")

if (file.isDirectory) {
		println("File is a Directory!")
} else {
		println("File is a normal File!")
}
 

Create Parent directories

Recall that writeText() function does not create directories but the file only (if it does not exist). To create directories, we have the mkDirs() function. But mkDirs() function can not be used on a normal file object to create its parent directories.

Suppose we need to create a file at path output\text\sample.txt. To do so, directories output and text must exist. If parent directories do not exist, then writeText() function will throw FileNotFoundException. To create parent directories, mkDirs() function can not be used like this :

 val file = getResFile("output\\text\\sample.txt")
file.mkDirs() // Won't create "output" and "text" directories
 

The reason being mkDirs() function works on File objects that point to Directory only and not normal file. Here, file object points to normal file sample.txt and not some directory. To create parent directories, mkDirs() has to be called on file’s parent directory i.e. text directory here.

To get an instance of parent directory, we can use the parentFile property :

 /* file */.parentFile
 

Then mkDirs() function can be invoked on it. Using this approach, we can define a new function mkParentDirs() that would create parent directories of a normal file object :

 fun File.mkParentDirs() {
    parentFile.apply { 
				if (!exists()) mkdirs() 
		}
}
 

Usage :

 val file = getResFile("output\\text\\sample.txt")
// file.mkDirs() <- Won't work
file.mkParentDirs() // Works (creates "output" & "text" directories)