Tcl Part I · Module 12 · Files, Directories, and Paths
eof and the End of a Stream
There is a reading loop that looks more natural than the one Chapter 12.2 gave you — keep going while we are not at the end of the file — and it is wrong. It processes one line that does not exist, every time, including on a file with no lines at all. The reason is worth understanding rather than memorising, because it is a general fact about how end-of-file detection works and it applies in other languages too: eof does not predict whether the next read will succeed. It reports whether a read has already failed. This chapter makes that concrete, counts the extra iteration, and then covers the cases where eof genuinely is the right question to ask.
Foundation10 min readTclPart IFileseofLoops
Part I · Module 12 · Chapter 12.7 · eof and the End of a Stream
1. The loop that looks right
set fp [open data.txt w]
foreach l {alpha beta gamma} { puts $fp $l }
close $fp
set fp [open data.txt r]
set n 0
while {![eof $fp]} {
gets $fp line
incr n
puts "iteration $n: '$line'"
}
close $fp
puts "-> $n iterations for a 3-line file"iteration 1: 'alpha'
iteration 2: 'beta'
iteration 3: 'gamma'
iteration 4: ''
-> 4 iterations for a 3-line fileFour iterations. The fourth processed an empty line that is not in the file.
Against the canonical loop from Chapter 12.2:
set fp [open data.txt r]
set n 0
while {[gets $fp line] >= 0} {
incr n
puts "iteration $n: '$line'"
}
close $fp
puts "-> $n iterations"iteration 1: 'alpha'
iteration 2: 'beta'
iteration 3: 'gamma'
-> 3 iterationsThree, which is the number of lines.
2. Why: eof is retrospective
eof does not mean "the next read will fail". It means "a read has already tried to go past the end". Watch it change:
set fp [open data.txt r]
puts "before any read : eof = [eof $fp]"
gets $fp line ; puts "after reading alpha : eof = [eof $fp]"
gets $fp line ; puts "after reading beta : eof = [eof $fp]"
gets $fp line ; puts "after reading gamma : eof = [eof $fp]"
gets $fp line ; puts "after the failed read: eof = [eof $fp]"
close $fpbefore any read : eof = 0
after reading alpha : eof = 0
after reading beta : eof = 0
after reading gamma : eof = 0
after the failed read: eof = 1After reading the third and last line, eof is still 0 — nothing has yet attempted to read past the end, so the channel has no reason to think it is finished. The loop therefore goes round once more, the gets fails, line is set to the empty string, and the body processes it.
3. It invents a line even when there are none
The empty file is the clearest case:
set fp [open empty.txt w] ; close $fp
set fp [open empty.txt r]
set n 0
while {![eof $fp]} {
gets $fp line
incr n
}
close $fp
puts "eof-first loop on an EMPTY file: $n iteration(s)"
set fp [open empty.txt r]
set n 0
while {[gets $fp line] >= 0} { incr n }
close $fp
puts "correct loop on an EMPTY file : $n iteration(s)"eof-first loop on an EMPTY file: 1 iteration(s)
correct loop on an EMPTY file : 0 iteration(s)A file with nothing in it produced one iteration. Any processing in that body — counting a record, appending to a list, incrementing a total — now operates on something that was never there.
4. Patching it is worse than replacing it
The usual repair is to test the line after reading it:
set fp [open data.txt r]
set n 0
while {![eof $fp]} {
gets $fp line
if {$line eq ""} { continue }
incr n
}
close $fp
puts "patched loop: $n"patched loop: 3Right answer, wrong reason — and it has now acquired a second bug, because it also skips genuinely blank lines:
set fp [open withblank.txt w]
puts $fp "first" ; puts $fp "" ; puts $fp "third"
close $fp
set fp [open withblank.txt r]
set n 0
while {![eof $fp]} {
gets $fp line
if {$line eq ""} { continue }
incr n
}
close $fp
puts "patched loop on a file with a blank line: $n (should be 3)"patched loop on a file with a blank line: 2 (should be 3)The patch cannot distinguish the phantom line from a real empty one, because both are the empty string — exactly the ambiguity Chapter 12.2 §6 described. Use the condition that carries the information instead.
5. Where eof is genuinely useful
Two cases, both after a loop rather than as its condition.
Telling a clean end from a problem. gets returning -1 means either "end of the data" or "the read could not complete". eof distinguishes them:
set fp [open data.txt r]
while {[gets $fp line] >= 0} { }
if {[eof $fp]} {
puts "finished: reached the end of the file"
} else {
puts "stopped early: the channel is not at eof"
}
close $fpfinished: reached the end of the fileOn a plain file that check is near enough always eof. It earns its keep on channels that can stall — a pipe, a socket, a non-blocking channel — where the distinction is real.
Checking for a missing final newline. A file whose last line has no terminator still yields that line, and eof is set at the same time:
set fp [open partial.txt w]
puts -nonewline $fp "complete line\nno terminator here"
close $fp
set fp [open partial.txt r]
while {[gets $fp line] >= 0} {
puts "'[set line]' (eof now: [eof $fp])"
}
close $fp'complete line' (eof now: 0)
'no terminator here' (eof now: 1)The last line came back normally and eof became 1 on the same call rather than the next one. If it matters whether the file was complete, that is how you tell.
6. A worked example
Reading a file and reporting honestly on how it ended:
proc read_report {path} {
set fp [open $path r]
set code [catch {
set lines {}
set blanks 0
while {[gets $fp line] >= 0} {
if {$line eq ""} { incr blanks }
lappend lines $line
}
list [llength $lines] $blanks [eof $fp]
} result]
close $fp
if {$code} { return -code error $result }
lassign $result n blanks ended
return "$n line(s), $blanks blank, ended at eof: $ended"
}
puts [read_report data.txt]
puts [read_report withblank.txt]
puts [read_report empty.txt]3 line(s), 0 blank, ended at eof: 1
3 line(s), 1 blank, ended at eof: 1
0 line(s), 0 blank, ended at eof: 1Blank lines are counted rather than lost, the empty file gives zero rather than one, and eof is reported as a fact about how the loop finished rather than used to decide when it should.
7. Exercises
Exercise 1. How many iterations does while {![eof $fp]} { gets $fp line } make on a three-line file?
Exercise 2. Why is eof still 0 immediately after reading the last line?
Exercise 3. How many iterations does the same loop make on an empty file?
Exercise 4. What second bug does the usual patch — skipping empty lines — introduce?
Exercise 5. Give one situation where eof is the right question.
Exercise 6. Rewrite this correctly:
while {![eof $fp]} {
gets $fp line
process $line
}Answers
1. Four. The fourth runs after the last line has been read, because eof is still false at that point; the gets fails and the body processes an empty string.
2. Because eof reports that a read has already failed, not that the next one will. After the third line of a three-line file, nothing has yet tried to read past the end, so there is nothing for eof to report.
3. One. eof is false before any read has been attempted, so the body runs once and processes a line that does not exist.
4. It cannot tell a phantom line from a real blank one — both are the empty string — so it silently drops genuine empty lines from the file.
5. After a loop, to distinguish a clean end of data from a read that stopped for another reason; or to detect that the final line had no terminator. Both are questions about what already happened, which is what eof answers.
6.
while {[gets $fp line] >= 0} {
process $line
}8. Remember
eofis retrospective. It reports that a read has already gone past the end, not that the next one will.- So
while {![eof $fp]}always runs one iteration too many — including exactly one iteration on an empty file. - The extra pass processes an empty string, which looks like a blank line and produces an off-by-one at the end of the output.
- Patching it by skipping empty lines adds a second bug — real blank lines disappear too.
- Use
while {[gets $fp line] >= 0}, which carries the information in its return value. eofis useful after a loop: distinguishing a clean end from a stall, and detecting a missing final newline.- The same trap exists in C and elsewhere for the same reason.
Where this fits
Part of the Tcl curriculum.
