episode 02

head(1)

read loops · line counting · -n and -c flags

What If We Want Less?

In the last chapter, cat gave us the streaming model: read bytes, write bytes, stop at EOF. The stream flows freely until there's nothing left.

Often we don't want all of it. We want the first few lines and nothing more, then we stop reading.

That's head. It looks like a small variation on cat. That small variation forces us to think carefully about what "stopping" actually means in a streaming context.

Errata: corrections to cat

Every chapter in this series ends with a working program, not a finished one. Now that the next tool is being built on top of it, here is what the previous chapter got wrong.

// errata 01: cat

Line numbering restarted for every file. Our cat -n a.txt b.txt printed 1, 2, 1, 2. The real tool prints 1, 2, 3, 4. The counter lived inside cat_fd(), so opening a second file reset it. This contradicts the chapter's own argument: concatenation produces one stream, so it has one sequence of lines. The counter belongs outside the per-file function.

We never checked what write() returned. Like read(), write() reports how many bytes it actually handled, and it is allowed to report fewer than we asked for. On a regular file this effectively never happens, which is why the programs appeared to work. On a pipe or a socket it does happen, and the unwritten remainder is silently lost. Correct code loops until the whole buffer is gone.

The numbering was not padded. Real cat -n right-aligns the number in a six-character field before the tab. Ours printed the bare digits. Cosmetic, but it is a visible difference in a chapter that ends by claiming the output is indistinguishable.

Rethinking head

The foundation is still the cat streaming loop. The difference is that instead of running until EOF, we impose an explicit limit: a gate that closes once we've passed enough data through.

cat vs head: what controls the stream
cat stops at EOF head limit stops at N bytes written bytes discarded

The streaming loop is identical. The difference is that head tracks how much has flowed through and closes the gate when the limit is reached.

The C Concept: Stream Control

There are two ways to measure "how much": bytes or lines. They solve different problems.

Byte counting is the simpler one. We know exactly how many bytes we've written, and we stop when that number reaches our limit. No need to inspect the content at all. Because every ASCII character occupies exactly one byte, counting bytes is the same as counting characters here.

Line counting requires us to look at the bytes as they flow through. Every time we see a '\n' character, we know one line has ended. That's the entirety of "line detection" in Unix text files: a single byte comparison.

byte counting vs line counting
h e l l o \n w o r l d \n f o o \n 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 -c 10 10 bytes -n 2 2 lines \n \n -c: count bytes regardless of content -n: count '\n' characters as line boundaries

Both modes use the same read loop. The only difference is what triggers the "stop" condition.

The real head defaults to line mode (-n 10) because text tools are primarily line-oriented. Users think in lines, not bytes. Log files, config files, source code: all of them are organized by line. Nobody inspecting a log thinks "show me the first 200 bytes." They think "show me the first 10 lines."

This is not unique to head. tail, grep, sed, and awk all process input one line at a time, and they all detect a line the same way we are about to: by watching for a single '\n' byte. Learn that one idea and the whole family of Unix text tools starts to make sense.

The Build

Step 1: Byte Limit

Start with the byte case. We reuse the cat loop and add a counter for how many bytes we are still allowed to send. The obvious first attempt looks like this:

head.c: first attempt (it overshoots)
void    stream_bytes_naive(int fd, int limit)
{
    char    buffer[BUFFER_SIZE];
    ssize_t bytes_read;

    while (limit > 0)
    {
        bytes_read = read(fd, buffer, BUFFER_SIZE);
        if (bytes_read <= 0)
            break;
        write(1, buffer, bytes_read);
        limit -= bytes_read;
    }
}

Compile it, ask for 10 bytes, and you get far more. The counter gates the loop, not the write. A single read() can return a full 1024-byte chunk, and we write the whole thing before checking the limit again. We blow past the limit on the very first iteration. The user's number never really controlled the stream.

The fix is to clamp each write to whatever budget is left:

head.c: byte streaming with limit
void    stream_bytes(int fd, int limit)
{
    char    buffer[BUFFER_SIZE];
    ssize_t bytes_read;
    ssize_t to_write;

    while (limit > 0)
    {
        bytes_read = read(fd, buffer, BUFFER_SIZE);
        if (bytes_read <= 0)
            break;
        to_write = bytes_read;
        if (to_write > limit)
            to_write = limit;
        write(1, buffer, to_write);
        limit -= to_write;
    }
}

The to_write = bytes_read then if (to_write > limit) to_write = limit pattern is the whole fix. When we read a 1024-byte chunk but only have 3 bytes left in the limit, we write exactly 3, not the full chunk. That clamp is what makes the limit exact, and it is the difference between gating the loop and gating the output.

Step 2: Line Limit

The line version scans each byte as it comes in. When a newline appears, we decrement the counter. When it reaches zero, we return immediately, mid-buffer if necessary.

head.c: line streaming (efficient, writes per line)
void    stream_lines(int fd, int limit)
{
    char    buffer[BUFFER_SIZE];
    ssize_t bytes_read;
    int     start;
    int     i;

    while (limit > 0)
    {
        bytes_read = read(fd, buffer, BUFFER_SIZE);
        if (bytes_read <= 0)
            break;
        start = 0;
        i = 0;
        while (i < bytes_read)
        {
            if (buffer[i] == '\n')
            {
                write(1, buffer + start, i - start + 1);
                limit--;
                if (limit == 0)
                    return;
                start = i + 1;
            }
            i++;
        }
        if (start < bytes_read)
            write(1, buffer + start, bytes_read - start);
    }
}

Notice we batch writes per line. write(1, buffer + start, i - start + 1) sends the whole line at once, not one byte at a time. System calls are expensive relative to normal CPU work. Writing one character per syscall is something you'd only do in an early draft to make the flow visible. The production version batches.

Step 3: Flag Parsing

The real head understands three invocation styles:

bash: the three ways to call head
head -n 20 file.txt     # first 20 lines
head -c 100 file.txt    # first 100 bytes
head -50 file.txt       # first 50 lines (historical shorthand)

We isolate the parsing into its own function. This keeps main() focused on file handling, its actual job, and makes the flag logic easy to read and test independently.

head.c: flag parser (returns first file arg index, or -1)
int     parse_flag(int argc, char **argv, int *mode, int *limit)
{
    if (argc <= 1)
        return (1);
    if (argv[1][0] != '-')
        return (1);
    if (argv[1][1] == 'c')
    {
        if (argc < 3 || !is_numeric(argv[2]))
        {
            write(2, "head: invalid byte count\n", 25);
            return (-1);
        }
        *mode  = 1;   /* byte mode */
        *limit = to_number(argv[2]);
        return (3);
    }
    if (argv[1][1] == 'n')
    {
        if (argc < 3 || !is_numeric(argv[2]))
        {
            write(2, "head: invalid line count\n", 25);
            return (-1);
        }
        *mode  = 0;   /* line mode */
        *limit = to_number(argv[2]);
        return (3);
    }
    if (is_numeric(&argv[1][1]))
    {
        *mode  = 0;
        *limit = to_number(&argv[1][1]);
        return (2);
    }
    write(2, "head: invalid option\n", 21);
    return (-1);
}

The return value does double duty: it's either the index of the first file argument, or -1 on error. This is a common Unix-style pattern: returning useful state through the return value instead of using a separate error channel.

The Full Implementation

head.c: complete
#include <unistd.h>
#include <fcntl.h>

#define BUFFER_SIZE  1024
#define MODE_LINES   0
#define MODE_BYTES   1

ssize_t str_len(char *s)
{
    ssize_t len;

    len = 0;
    while (s && s[len])
        len++;
    return (len);
}

int     is_numeric(char *s)
{
    if (!s)
        return (0);
    while (*s)
    {
        if (*s < '0' || *s > '9')
            return (0);
        s++;
    }
    return (1);
}

int     to_number(char *s)
{
    int     res;

    res = 0;
    while (*s >= '0' && *s <= '9')
    {
        res = res * 10 + (*s - '0');
        s++;
    }
    return (res);
}

void    stream_bytes(int fd, int limit)
{
    char    buffer[BUFFER_SIZE];
    ssize_t bytes_read;
    ssize_t to_write;

    while (limit > 0)
    {
        bytes_read = read(fd, buffer, BUFFER_SIZE);
        if (bytes_read <= 0)
            break;
        to_write = bytes_read;
        if (to_write > limit)
            to_write = limit;
        write(1, buffer, to_write);
        limit -= to_write;
    }
}

void    stream_lines(int fd, int limit)
{
    char    buffer[BUFFER_SIZE];
    ssize_t bytes_read;
    int     start;
    int     i;

    while (limit > 0)
    {
        bytes_read = read(fd, buffer, BUFFER_SIZE);
        if (bytes_read <= 0)
            break;
        start = 0;
        i = 0;
        while (i < bytes_read)
        {
            if (buffer[i] == '\n')
            {
                write(1, buffer + start, i - start + 1);
                limit--;
                if (limit == 0)
                    return;
                start = i + 1;
            }
            i++;
        }
        if (start < bytes_read)
            write(1, buffer + start, bytes_read - start);
    }
}

void    print_header(char *filename)
{
    write(1, "==> ", 4);
    write(1, filename, str_len(filename));
    write(1, " <==\n", 5);
}

int     parse_flag(int argc, char **argv, int *mode, int *limit)
{
    if (argc <= 1)
        return (1);
    if (argv[1][0] != '-')
        return (1);
    if (argv[1][1] == 'c')
    {
        if (argc < 3 || !is_numeric(argv[2]))
        {
            write(2, "head: invalid byte count\n", 25);
            return (-1);
        }
        *mode  = MODE_BYTES;
        *limit = to_number(argv[2]);
        return (3);
    }
    if (argv[1][1] == 'n')
    {
        if (argc < 3 || !is_numeric(argv[2]))
        {
            write(2, "head: invalid line count\n", 25);
            return (-1);
        }
        *mode  = MODE_LINES;
        *limit = to_number(argv[2]);
        return (3);
    }
    if (is_numeric(&argv[1][1]))
    {
        *mode  = MODE_LINES;
        *limit = to_number(&argv[1][1]);
        return (2);
    }
    write(2, "head: invalid option\n", 21);
    return (-1);
}

int     main(int argc, char **argv)
{
    int     fd;
    int     i;
    int     limit;
    int     mode;
    int     total_files;
    int     first_file;
    int     arg_index;

    limit     = 10;
    mode      = MODE_LINES;
    arg_index = parse_flag(argc, argv, &mode, &limit);
    if (arg_index < 0)
        return (1);
    i = arg_index;
    if (i >= argc)
    {
        if (mode == MODE_BYTES)
            stream_bytes(0, limit);
        else
            stream_lines(0, limit);
        return (0);
    }
    total_files = argc - i;
    first_file  = i;
    while (i < argc)
    {
        fd = open(argv[i], O_RDONLY);
        if (fd == -1)
        {
            write(2, "head: cannot open ", 18);
            write(2, argv[i], str_len(argv[i]));
            write(2, "\n", 1);
            i++;
            continue;
        }
        if (total_files > 1)
        {
            if (i > first_file)
                write(1, "\n", 1);
            print_header(argv[i]);
        }
        if (mode == MODE_BYTES)
            stream_bytes(fd, limit);
        else
            stream_lines(fd, limit);
        close(fd);
        i++;
    }
    return (0);
}

When Multiple Files Are Given

Like cat, head accepts more than one file. If we streamed them back to back, the output would be one undivided block and you could not tell where one file ended and the next began. The real head prints a header before each file, but only when more than one file was given.

bash: headers appear only with multiple files
$ ./myhead -n 2 a.txt b.txt
==> a.txt <==
alpha
beta

==> b.txt <==
gamma
delta

Two details carry their weight here. The header goes to fd 1, the same stream as the content, so it travels with the output through any pipe. And there is a blank line before every header except the first. That single blank line is what makes a multi-file dump scannable. It looks cosmetic, but it is the kind of small, deliberate choice that separates a tool people tolerate from one they reach for. The print_header helper and the total_files > 1 check in main are exactly this behaviour.

The Test

bash: compare against the real head
$ cc -Wall -Wextra -o myhead head.c

# default: first 10 lines
$ diff <(head head.c) <(./myhead head.c)

# explicit line count
$ diff <(head -n 3 head.c) <(./myhead -n 3 head.c)

# byte mode
$ diff <(head -c 50 head.c) <(./myhead -c 50 head.c)

# shorthand form
$ diff <(head -5 head.c) <(./myhead -5 head.c)

# multiple files: check headers and blank lines
$ diff <(head head.c head.c) <(./myhead head.c head.c)

# file shorter than limit
$ printf "only\ntwo\nlines\n" > short.txt
$ diff <(head -n 10 short.txt) <(./myhead -n 10 short.txt)

What We Actually Learned

The pattern we established in this episode shows up constantly in Unix tools: a streaming loop driven by a counter that tracks how much of some resource is still allowed. The resource changes (bytes, lines, characters), but the loop stays the same.

We also saw that parsing command-line flags deserves its own function. When argument handling lives inside main(), the program becomes harder to read and harder to extend. Isolating it early is a discipline worth building now, before the programs get complicated.

One more thing: the difference between writing one byte at a time and writing whole lines at once. Functionally identical. Performance-wise, not even close. Minimizing system calls isn't premature optimization when you're building tools that run on every file a user might throw at them.

Next: tail(1). Everything we've built so far reads from the beginning. tail breaks that assumption, and the solution requires a data structure we haven't needed yet.