1. Introduction
When you’re just starting out, you really want everything to be like a fairy tale: val text = file.readText() — and you’re done. But “convenience methods” end where real life and real file sizes begin.
First, not all files are text. If you try to “read as text” a PNG image, you’ll get a bunch of weird characters (and, possibly, the feeling that the computer is scolding you in an ancient language). Second, even if the file is text, it can be large: reading it all at once means loading the entire file into memory. Sometimes that’s fine; sometimes it’s very expensive.
Streams solve both problems: they give you a “read/write gradually” model, in small chunks. And most importantly: the streaming model works the same for binary files and for text files (although for text it’s often more convenient to use higher-level Reader/Writer).
Byte streams and file implementations
The easiest way to imagine streams on the JVM is with an everyday analogy. InputStream is a tap you can get water (bytes) from. OutputStream is a sink (or a drain hose) where you can pour water (bytes). And you don’t have to dump the whole pool at once—you can work with a cup: fill, pour, fill, pour.
In code terms:
- InputStream is a source of bytes; we call read(...) on it.
- OutputStream is a destination for bytes; we call write(...) on it.
The nice part is that your code can be fairly universal: if you learned how to read from an InputStream, you almost don’t care where those bytes come from: a file, the network, a ZIP archive—anything.
FileInputStream and FileOutputStream
Now for a bit of specifics. On the JVM there are concrete stream implementations for files:
- FileInputStream reads bytes from a file.
- FileOutputStream writes bytes to a file.
They live in the java.io package, and you can create them from a string path or from a File.
It’s important to remember two practical points.
First: a stream is a resource. By opening a stream, you “hold” the file. If you forget to close the stream, the program may keep the file busy longer than you expect, and sometimes this leads to issues on Windows (and not only there).
Second: the streaming model does not promise that “one call to read” will read everything you want. It only promises an honest contract: “I read as much as I could; here is the number of bytes.” And that contract is the heart of today’s lecture.
2. Reading and writing: read, EOF, and write
read() and the value -1
In its simplest form, InputStream.read() reads one byte. But it returns not a Byte, but an Int. At first this looks like a strange whim of the Java designers, but it’s actually very practical: you need somewhere to store a special value meaning “there are no more bytes.”
The read() contract (one byte):
- A return value of 0..255 means “a byte with this numeric value was read.”
- A return value of -1 means EOF — End Of File, “we reached the end of the file.”
So -1 is not a byte; it’s the signal “the file is over.”
Mini example: read the first byte of a file and print it.
import java.io.FileInputStream
fun main() {
FileInputStream("in.bin").use { input ->
val first = input.read()
println("first=$first") // first=0..255, or first=-1 if the file is empty
}
}
Here use guarantees closing the stream. And yes: if the file is empty, read() will immediately return -1. That’s not an error; it’s a normal situation: “there’s nothing to read.”
The Byte pitfall in Kotlin
In Kotlin, Byte is signed (-128..127). But a file byte is just 8 bits (0..255 if you view it as an unsigned number). That’s why the stream returns an Int: it’s easier not to confuse “a byte as data” with -1 “as a signal.”
read(buffer) — read a chunk of data and get n
Reading one byte at a time is possible, but it’s almost always slow and inconvenient. So most often people use reading into an array: read(buffer).
And here a new hero appears: ByteArray. This is a “byte array,” i.e., a container for raw data. In Kotlin it belongs to primitive arrays, like IntArray, DoubleArray, etc.
The read(buffer: ByteArray): Int contract:
- Returns n > 0: exactly n bytes were actually read and written into buffer[0]..buffer[n-1].
- Returns -1: EOF, there are no more bytes (and you should not read anything useful from the buffer).
Important: read(buffer) can read fewer bytes than the buffer size. Even if the file is large. Even if you really asked. That’s a normal part of the contract: “I gave you as much as I could.”
Mini example: try to read up to 16 bytes and print how many we got.
import java.io.FileInputStream
fun main() {
FileInputStream("in.bin").use { input ->
val buffer = ByteArray(16)
val n = input.read(buffer)
println("n=$n") // n=1..16 or -1
}
}
If n == 6, it means: the actual data is only in buffer[0]..buffer[5]. Everything else in buffer is either old values (if you reuse the buffer) or zeros (if the array was just created), but that is no longer “read data.”
OutputStream.write(...): write exactly what you read
Now the mirror side: writing. In OutputStream there are several variants of write, but right now we care about two:
- write(byteArray) — write the entire array.
- write(byteArray, offset, length) — write part of the array.
And here’s an important point that ties into reading: if you read into a buffer and got n, then you must write exactly n bytes, not the entire buffer. Otherwise you’ll “append a tail”—extra bytes that were not actually present in the file.
Mini example: read a chunk, write exactly that chunk.
import java.io.FileInputStream
import java.io.FileOutputStream
fun main() {
FileInputStream("in.bin").use { input ->
FileOutputStream("out.bin").use { output ->
val buffer = ByteArray(16)
val n = input.read(buffer)
if (n != -1) {
output.write(buffer, 0, n) // write only the actual bytes
}
}
}
}
Note: this is not a full file copy. We read only one chunk and wrote only once. A full transfer is done with a loop—and that’s exactly the topic of the next lecture. Today it’s important to automate the very linkage “read returns n → you must write 0..n-1”.
3. Closing streams and use {}
Streams must be closed. Always. Even if you “just quickly” read 3 bytes and ran away.
In Java this was often done with try-with-resources, and in Kotlin the idiomatic option is use {}. It works for resources that must be closed (in particular, file streams) and guarantees closing even if an exception happens inside the block.
To feel the meaning, it’s useful to compare two versions: manual try/finally and use.
Manual version: try/finally
import java.io.FileInputStream
fun main() {
val input = FileInputStream("in.bin")
try {
val b = input.read()
println("b=$b")
} finally {
input.close()
}
}
This works, but it requires discipline: don’t forget finally, don’t forget close(), don’t lose the stream on early return.
Kotlin standard version: use
import java.io.FileInputStream
fun main() {
FileInputStream("in.bin").use { input ->
val b = input.read()
println("b=$b")
}
}
The meaning is the same, but the chance of “forgetting to close” is much lower. And when your code becomes more complex (and it will), this will start saving you time and nerves.
Nested use is normal
When you need to open both an input and an output stream, nesting is usually used. Yes, it looks like a “nesting doll,” but it reads just fine, especially if the blocks are small:
import java.io.FileInputStream
import java.io.FileOutputStream
fun main() {
FileInputStream("in.bin").use { input ->
FileOutputStream("out.bin").use { output ->
val x = input.read()
if (x != -1) output.write(x)
}
}
}
4. Mini-project FileLab: peekbin
So the examples aren’t “in a vacuum,” let’s imagine we already have a simple console application FileLab that we used for reading/writing text. Today we’ll add a small feature to it: “peek at the first bytes of a file,” to make sure we’re really working with binary data, not text.
Let’s have a function readFirstBytes that reads up to maxCount bytes (in a single chunk) and returns the actual number read. Note: we intentionally do a single read(buffer), without a loop, because the correct loop is a separate topic for the next lecture.
import java.io.FileInputStream
fun readFirstBytes(path: String, maxCount: Int): ByteArray {
FileInputStream(path).use { input ->
val buffer = ByteArray(maxCount)
val n = input.read(buffer)
if (n == -1) return ByteArray(0)
return buffer.copyOf(n) // keep only the actual bytes
}
}
There are several important ideas here.
We create ByteArray(maxCount) as a “receiver container.” Then we read and get n. If n == -1, the file is empty (or we’re already at the end) — return an empty array. If n > 0, we return not the whole buffer, but copyOf(n), so the result is honest: exactly as many bytes as we actually read.
Now we can make a small main that prints bytes “as numbers”:
fun main() {
val bytes = readFirstBytes("in.bin", maxCount = 8)
println("read=${bytes.size}") // read=0..8
println(bytes.joinToString()) // for example: 137, 80, 78, 71, ...
}
If you take a PNG file and read the first bytes, you’ll often see the PNG signature (it starts with 137, 80, 78, 71...), and that’s a nice “aha” moment: a file is bytes, and we can work with them directly, without pretending it’s text.
Cheat-sheet table for the read and write contracts
Sometimes beginners lack a short “map of the terrain”: what function you called—what you got—what to check. Let’s lock it in with a table.
| Operation | What it does | What it returns | What you must check |
|---|---|---|---|
|
reads 1 byte | Int: 0..255 or -1 | -1 means EOF |
|
reads a chunk of bytes | Int: 1..buffer.size or -1 | n is how many bytes are actually in buffer[0..n-1] |
|
writes the whole array | nothing (Unit) | make sure the array is truly “fully current” |
|
writes part of the array | nothing (Unit) | n must be the same value returned by read |
It’s useful to keep this table in your head until it becomes automatic, because later (in file copying and in ZIP archives) you’ll have the same pattern: “read → got n → wrote n”.
5. Common mistakes when working with InputStream/OutputStream, EOF, and use
Mistake #1: ignore -1 and assume read() always returns a byte.
Beginners often write code that takes the result of read() and immediately uses it as data. But -1 is not a byte; it’s the EOF signal. If you don’t handle it, you start “writing” an extra value to the output file or building logic on garbage. The right habit is to always keep in mind: -1 means “that’s it, we’re done.”
Mistake #2: call output.write(buffer) after val n = input.read(buffer).
This is probably the most common logical mistake with streams. read(buffer) almost never guarantees that it will fill the entire buffer, especially on the last chunk of data. If you write the whole buffer, you add a “tail”—bytes that were not read from the file. As a result, the copied file becomes corrupted, and then you spend half a day looking for why “the image won’t open.”
Mistake #3: expect a single read(buffer) to be enough to read the required amount.
Streams are a gradual model. read(buffer) can return less than buffer.size, and that’s normal. To reliably transfer the entire file or read exactly N bytes, you usually need a loop that repeats read until EOF or until the required size is reached. Today we only felt this at the contract level; the full pattern will be in the next lecture.
Mistake #4: not closing streams, or closing them “somewhere later.”
A file stream is a resource that must be closed quickly and reliably. If you close it “somewhere later,” then on an exception or early exit the stream will remain open, and you’ll get effects like “the file can’t be deleted,” “the file is in use,” “data wasn’t fully written.” use {} is practically the de facto standard, and it’s best to make it an automatic habit right away.
Mistake #5: confuse a byte as a number with Byte in Kotlin.
Byte in Kotlin is signed, and so the byte value 200 will look negative if you mindlessly cast types. That’s not “broken data,” it’s just representation. If you need to print bytes as numbers 0..255, it’s more convenient to work with Int (for example, via toInt() and 0xFF) or remember that InputStream.read() returns Int, and ByteArray stores raw bytes “as is.”
GO TO FULL VERSION