Ch11.11: C-Style I/O

Overview

C provides a set of I/O facilities built around FILE*, along with functions such as fopen, fclose, printf, scanf, fprintf, and fscanf. These are the oldest and most widely used I/O primitives in the C and C++ ecosystems.

fast_io wraps the C FILE* interface so that you can use it with print/println/scan while keeping RAII semantics, type safety, and performance.

Standard C I/O: <cstdio>

Before discussing how fast_io wraps C I/O, let’s review the standard C I/O API itself. All C I/O functions are declared in <cstdio> (or <stdio.h> in C). The core abstraction is FILE*, an opaque pointer to a stream that represents an open file, pipe, or device.

Opening and Closing Files


#include <cstdio>

int main() {
    // Open a file for writing
    FILE* fp = std::fopen("output.txt", "w");
    if (!fp) {
        return 1;  // Failed to open
    }

    // ... use the file ...

    // Always close when done
    std::fclose(fp);
}

The mode string controls how the file is opened:

Formatted Output: printf, fprintf


#include <cstdio>

int main() {
    int x = 42;
    double pi = 3.14159;
    char const* name = "Alice";

    // printf: write to stdout
    std::printf("Name: %s, Age: %d, Pi: %.2f\n", name, x, pi);

    // fprintf: write to a FILE*
    FILE* fp = std::fopen("data.txt", "w");
    std::fprintf(fp, "x = %d, pi = %f\n", x, pi);
    std::fclose(fp);

    // sprintf: write to a string buffer
    char buffer[100];
    std::sprintf(buffer, "Value: %d", x);
}

Common format specifiers:

Formatted Input: scanf, fscanf


#include <cstdio>

int main() {
    int x;
    double y;

    // scanf: read from stdin
    std::printf("Enter two numbers: ");
    std::scanf("%d %lf", &x, &y);

    // fscanf: read from a FILE*
    FILE* fp = std::fopen("data.txt", "r");
    std::fscanf(fp, "x = %d, y = %lf", &x, &y);
    std::fclose(fp);

    // sscanf: read from a string
    char const* input = "42 3.14";
    std::sscanf(input, "%d %lf", &x, &y);
}

Warning: The scanf family is prone to buffer overflows and format string vulnerabilities. Always validate input and never pass user-controlled strings as format specifiers.

Character and Line I/O


#include <cstdio>

int main() {
    FILE* fp = std::fopen("example.txt", "r");

    // Read a single character
    int ch = std::fgetc(fp);

    // Read a line (up to n-1 characters or newline)
    char buffer[256];
    std::fgets(buffer, sizeof(buffer), fp);

    // Write a single character
    std::fputc('A', stdout);

    // Write a string
    std::fputs("Hello, World!\n", stdout);

    std::fclose(fp);
}

Binary I/O


#include <cstdio>

int main() {
    // Write binary data
    FILE* fp = std::fopen("data.bin", "wb");
    int numbers[] = {1, 2, 3, 4, 5};
    std::fwrite(numbers, sizeof(int), 5, fp);
    std::fclose(fp);

    // Read binary data
    fp = std::fopen("data.bin", "rb");
    int buffer[5];
    std::size_t count = std::fread(buffer, sizeof(int), 5, fp);
    std::fclose(fp);
}

Standard Streams

C provides three predefined streams:


#include <cstdio>

int main() {
    std::fprintf(stdout, "This goes to stdout\n");
    std::fprintf(stderr, "This goes to stderr\n");

    // printf() is equivalent to fprintf(stdout, ...)
    std::printf("Same as stdout\n");
}

Appendix: Complete C I/O Function Reference

Function Description
fopen Open a file
fclose Close a file
fflush Flush a stream’s buffer
printf Formatted output to stdout
fprintf Formatted output to a stream
sprintf Formatted output to a string buffer
snprintf Formatted output to a string buffer with size limit
vprintf Formatted output to stdout (variadic)
vfprintf Formatted output to a stream (variadic)
vsprintf Formatted output to a string buffer (variadic)
vsnprintf Formatted output to a string buffer with size limit (variadic)
scanf Formatted input from stdin
fscanf Formatted input from a stream
sscanf Formatted input from a string
fgetc / getc Read a character from a stream
fgets Read a line from a stream
fputc / putc Write a character to a stream
fputs Write a string to a stream
gets Read a line from stdin (deprecated, unsafe)
puts Write a string to stdout (adds newline)
ungetc Push a character back onto a stream
fread Read binary data from a stream
fwrite Write binary data to a stream
fseek Set file position
ftell Get current file position
rewind Set file position to beginning
fgetpos Get file position (portble)
fsetpos Set file position (portable)
feof Test for end-of-file
ferror Test for error
clearerr Clear error and end-of-file indicators
perror Print error message to stderr
remove Delete a file
rename Rename a file
tmpfile Create a temporary file
tmpnam Generate a temporary filename (deprecated)
setvbuf Set stream buffering mode
setbuf Set stream buffer

fast_io Wrappers for FILE*

There are four types you need to know:

Using c_file with print/println

c_file opens a file from a path and exposes it to fast_io’s I/O system. You can use print, println, and scan on it just like any other fast_io file type.


#include <fast_io.h>
#include <fast_io_legacy.h>

int main()
{
    using namespace ::fast_io::iomnp;

    // Open a file for writing using C's fopen under the hood.
    ::fast_io::c_file cf("output.txt", ::fast_io::open_mode::out);

    // Use fast_io's print with the c_file wrapper.
    print(cf, "Hello from c_file!\n");

    ::std::size_t const answer{42zu};
    println(cf, "The answer is: ", answer);

    // cf is closed automatically when it goes out of scope.
}

Wrapping an Existing FILE*

If you already have a FILE* from C code (for example, from a library or from stdout), use c_io_observer to wrap it without taking ownership.


#include <cstdio>
#include <fast_io.h>
#include <fast_io_legacy.h>

int main()
{
    using namespace ::fast_io::iomnp;

    // Open a file with standard C API.
    FILE* fp{::std::fopen("data.txt", "w")};
    if (!fp)
    {
        return 1;
    }

    {
        // Wrap the raw FILE* in a non-owning observer.
        ::fast_io::c_io_observer ciob{fp};
        print(ciob, "Written through c_io_observer\n");

        ::std::size_t const n{100zu};
        println(ciob, "Count: ", n);
    }

    // You are still responsible for closing the FILE* yourself.
    ::std::fclose(fp);
}

To take ownership of an existing FILE*, construct a c_file with ::fast_io::from FILE or the appropriate constructor so that it is closed automatically:


#include <cstdio>
#include <fast_io.h>
#include <fast_io_legacy.h>

int main()
{
    using namespace ::fast_io::iomnp;

    FILE* fp{::std::fopen("owned.txt", "w")};
    if (!fp)
    {
        return 1;
    }

    // Take ownership: c_file will call fclose on destruction.
    ::fast_io::c_file cf{fp};
    print(cf, "Now owned by fast_io::c_file\n");
}

Thread Safety with ::fast_io::native_mutex

The locked variants (c_file and c_io_observer) rely on the C stream’s own internal lock. If you are using the unlocked variants, you must synchronize access yourself. ::fast_io::native_mutex provides a portable, fast mutex for this purpose.

The key performance insight: instead of locking and unlocking the mutex for every single write, you should lock once, then use the unlocked handle to write as much data as you need, and finally release the lock. This avoids the overhead of acquiring and releasing the mutex repeatedly.

A common use case is writing to stdout. The standard streams are shared across the whole process, so if you want to use the fast unlocked variants, you need your own mutex.


#include <fast_io.h>
#include <fast_io_legacy.h>

::fast_io::native_mutex stdout_mtx;

void print_report()
{
    // Get the raw FILE* for stdout and wrap it unlocked.
    ::fast_io::c_io_observer_unlocked ciob{stdout};

    // Lock once, print everything, then unlock.
    ::fast_io::io_lock_guard guard{stdout_mtx};

    // All prints inside this scope use the unlocked handle
    // without any further locking overhead.
    print(ciob, "=== Report ===\n");
    for (int i{}; i != 100; ++i)
    {
        print(ciob, "Row ", i, ": ", i * i, "\n");
    }
    print(ciob, "=== End ===\n");
}

int main()
{
    using namespace ::fast_io::iomnp;
    print_report();
}

Important: Format String Safety

When you call C’s printf family, you must never pass a runtime string directly as the format string. If the string contains a % character, printf will try to interpret it as a format specifier, which leads to undefined behaviour, crashes, or security vulnerabilities.

Wrong — format string vulnerability:

// DANGEROUS: If `user_input` contains "%s" or "%n", this is undefined behaviour.
char const* user_input{"helloworld\n"};
printf(user_input);   // WRONG
Right — always use a format specifier:

// SAFE: The format string is a compile-time literal; the data is passed as an argument.
char const* user_input{"helloworld\n"};
printf("%s", user_input);   // RIGHT

With fast_io, this class of bugs does not exist. print and println do not use format strings at all — every argument is typed and formatted independently.


#include <fast_io.h>

int main()
{
    using namespace ::fast_io::iomnp;

    // No format string, no vulnerability.
    char const* user_input{"helloworld\n"};
    // Wrap the C-string with os_c_str manipulator
    print(::fast_io::mnp::os_c_str(user_input));
}

Reading with scan

Just as fscanf reads formatted input from a FILE*, you can use scan on a c_file or c_io_observer.


#include <fast_io.h>
#include <fast_io_legacy.h>

int main()
{
    using namespace ::fast_io::iomnp;

    ::fast_io::c_file cf("input.txt", ::fast_io::open_mode::in);

    ::std::size_t a{};
    ::std::size_t b{};
    scan(cf, a, b);

    ::fast_io::c_io_observer ciob_stdout{stdout};
    println(ciob_stdout, "a + b = ", a + b);
}

Standard Streams: c_stdin, c_stdout, c_stderr

fast_io provides convenient functions to access the standard streams:

When you call print() or scan() without specifying a device as the first parameter, they automatically use ::fast_io::c_stdout() and ::fast_io::c_stdin() respectively:


#include <fast_io.h>
#include <fast_io_legacy.h>

int main()
{
    using namespace ::fast_io::iomnp;

    // print() without device defaults to c_stdout()
    print("Enter a number: ");

    // scan() without device defaults to c_stdin()
    ::std::size_t n{};
    scan(n);

    // println() without device also defaults to c_stdout()
    println("You entered: ", n);

    // You can also be explicit:
    print(::fast_io::c_stdout(), "Same as above\n");
    scan(::fast_io::c_stdin(), n);

    // Use c_stderr() for error messages
    if (n == 0) {
        print(::fast_io::c_stderr(), "Error: number is zero\n");
    }
}

These functions return non-owning observers, so they don't close the underlying streams. The implicit default to stdout/stdin makes simple I/O very concise.

Key Takeaways