Writing Data to Files
Learn to write and append data to files using SDL3's I/O functions, and an introduction to serialization libraries.
In this lesson, we'll introduce how to write data from our program to external sources. We'll focus on writing to files for now, but the techniques we cover lay the foundations for working with other data streams, such as network traffic.
As we might expect, SDL's mechanism for writing data shares much in common with its API for reading data. We'll rely on SDL_IOStream objects, and the functions we use will be familiar to what we learned in the previous lesson.
Like before, we'll start with a basic main function that initializes SDL, and calls Write() and Read() functions within an included File namespace.
Files
Running our program, we should see an error output from our Read() function, as it's trying to read a file that we haven't created yet:
Error loading file: Couldn't open output.txtUsing SDL_IOFromFile()
We introduced the SDL_IOFromFile() function in the previous lesson. It returns an SDL_IOStream object, which is SDL's standard interface for interacting with data streams, such as files.
In the previous lesson, we passed the file path and the "rb" open mode to read the file. We'll use the same technique here, except we'll pass "wb" as the open mode, as we want to write to the file. We cover open modes in more detail later in this lesson.
Let's update our File::Write() function to create an SDL_IOStream handle for outputting content. Similar to the previous lesson, we'll also add an error check, and we'll close the file using SDL_CloseIO() when we're done:
src/File.h
// ...
namespace File {
// ...
void Write(const std::string& Path) {
SDL_IOStream* Handle{
SDL_IOFromFile(Path.c_str(), "wb")};
if (!Handle) {
std::cout << "Error opening file: "
<< SDL_GetError();
}
// Use file...
SDL_CloseIO(Handle);
}
}The "wb" open mode will additionally cause SDL to create a file if it doesn't exist. Therefore, running our program, after these changes we should now see our Read() function can successfully open the file, although it has no content yet:
Content:Using SDL_WriteIO()
The SDL_WriteIO() function is one of the main ways we output a collection of objects or values to a file. Let's update our File::Write() function to use it to output the C-style string "Hello World".
We introduced SDL_ReadIO() in the previous lesson, and the arguments to SDL_WriteIO() follow a similar pattern. It accepts 3 arguments:
- The
SDL_IOStreamhandle to use. - A pointer to the memory address where the content we want to write begins. Remember, a C-style string is simply a location in memory containing a contiguous sequence of
charvalues, represented by a pointer to the first character - achar*. - The total number of bytes to write. We can use the
strlen()function to determine how many characters are in a C-style string, and sincecharis 1 byte, this gives us our total bytes.
Let's update our File::Write() function to make use of this:
src/File.h
// ...
namespace File {
// ...
void Write(const std::string& Path) {
SDL_IOStream* Handle{
SDL_IOFromFile(Path.c_str(), "wb")};
if (!Handle) {
std::cout << "Error opening file: "
<< SDL_GetError();
}
const char* Content{"Hello World"};
SDL_WriteIO(Handle, Content, strlen(Content));
SDL_CloseIO(Handle);
}
}Content: Hello WorldSDL_WriteIO() returns the number of bytes it wrote:
src/File.h
// ...
namespace File {
// ...
void Write(const std::string& Path) {
SDL_IOStream* Handle{
SDL_IOFromFile(Path.c_str(), "wb")
};
if (!Handle) {
std::cout << "Error opening file: "
<< SDL_GetError();
}
const char* Content{"Hello World"};
size_t BytesWritten{SDL_WriteIO(
Handle, Content, strlen(Content)
)};
std::cout << BytesWritten
<< " bytes written\n";
SDL_CloseIO(Handle);
}
}11 bytes written
Content: Hello WorldWe can add some simple error checking by comparing this return value to what we expected. As usual, we can call SDL_GetError() for information on errors detected by SDL:
src/File.h
// ...
namespace File {
// ...
void Write(const std::string& Path) {
SDL_IOStream* Handle{
SDL_IOFromFile(Path.c_str(), "wb")};
if (!Handle) {
std::cout << "Error opening file: "
<< SDL_GetError();
}
const char* Content{"Hello World"};
size_t ContentLength{strlen(Content)};
size_t BytesWritten{SDL_WriteIO(
Handle, Content, ContentLength
)};
std::cout << BytesWritten
<< " bytes written\n";
if (BytesWritten < ContentLength) {
std::cout << "Expected " << ContentLength
<< " - Error: " << SDL_GetError() << '\n';
}
SDL_CloseIO(Handle);
}
}File Open Modes
In the previous lesson, we passed the rb string, telling SDL (and ultimately the underlying platform) that we wanted to read from the file in binary mode. In this case, we're using wb , indicating we want to create a new file for writing in binary mode.
If a file with the same name already exists, it will be entirely replaced when using wb. We'll cover more open modes, including the ability to edit or add to an existing file later in this chapter.
Binary Mode vs Text Mode
Platforms typically offer two ways to read or write files. These are commonly called binary mode and text mode. Binary mode reads and writes data in the exact format provided. This is intended when the data is used to communicate between programs (or within the same program), where a consistent and predictable format is useful.
On the other hand, text mode performs some platform-specific transformations to format the file in a way that conforms to that platform's conventions.
For example, we use \n to represent new lines in this course, but some platforms use a two-byte sequence: \r\n. Text mode aims to handle those transformations. However, even if our program is writing data that is intended to be read by humans, that data is usually going to be read in some program that handles formatting, so binary mode is typically preferred even in that scenario.
As such, text mode is rarely useful, but can be used by removing the b character from open modes if needed:
// Open a file for reading in text mode
SDL_IOFromFile("input.txt", "r");
// Open a file for writing in text mode
SDL_IOFromFile("output.txt", "w");Adding to Existing Files
As we've seen, opening a file in "write" mode (such as "wb") ensures we get a new file every time we create our handle. If a file with that name already exists, it will be replaced.
In contrast, we can open a file in an "append" mode, such as "ab":
SDL_IOFromFile("some-file.txt", "ab");This will also create the file if it doesn't exist yet. However, if it does exist, write operations will be done at the end of the existing file, preventing us from losing any of the existing data.
This is primarily useful for logging scenarios, where we want to keep track of the actions our program performed:
Files
Three file handles were opened and closed during the execution of the program, and each write operation was appended to the previous output:
Content: OneTwoThreeIf we run our program again, the logging from the second execution will add more content to the file, without replacing the output of the previous execution:
Content: OneTwoThreeOneTwoThreeWriting Comma-Separated Values
Often when working with data files, we want to store multiple related values in a structured format. Comma-separated values (CSV) files are a common choice for this.
Let's extend our File namespace to support writing multiple strings as CSV records. First, we'll create an overload of our Write() function that accepts a vector of strings:
src/File.h
// ...
namespace File {
// ...
void Write(
const std::string& Path,
const std::vector<std::string>& Values
) {
SDL_IOStream* Handle{
SDL_IOFromFile(Path.c_str(), "wb")
};
if (!Handle) {
std::cout << "Error opening file: "
<< SDL_GetError();
return;
}
for (size_t i = 0; i < Values.size(); ++i) {
const std::string& Value{Values[i]};
SDL_WriteIO(Handle, Value.c_str(), Value.length());
// Add comma between values, newline at end
if (i < Values.size() - 1) {
SDL_WriteIO(Handle, ",", 1);
} else {
SDL_WriteIO(Handle, "\n", 1);
}
}
SDL_CloseIO(Handle);
}
}We can use this new function like so:
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <vector>
#include "File.h"
int main(int, char**) {
SDL_Init(0);
std::vector<std::string> Record{
"John", "Doe", "42", "Engineer"
};
File::Write("data.csv", Record);
File::Read("data.csv");
return 0;
}This will create a file containing:
Content: John,Doe,42,EngineerWriting Numeric Types
While we've focused on writing string data so far, often we need to write numeric values to files.
Since SDL_WriteIO() works with raw memory, we'll need to convert our numbers to strings first if we want them to be human-readable. Here's a function that demonstrates this:
src/File.h
// ...
namespace File {
// ...
void WriteNumber(
const std::string& Path,
auto Number
) {
SDL_IOStream* Handle{
SDL_IOFromFile(Path.c_str(), "wb")
};
if (!Handle) {
std::cout << "Error opening file: "
<< SDL_GetError();
return;
}
// Convert number to string
std::string Str{std::to_string(Number)};
SDL_WriteIO(Handle, Str.c_str(), Str.length());
SDL_CloseIO(Handle);
}
}Note that the auto type in our number parameter means WriteNumber() is a template function. We cover template functions in our advanced course but, for now, we can just note that it allows our function to be called with various numeric types:
File::WriteNumber("integer.txt", 42);
File::WriteNumber("float.txt", 3.14159f);
File::WriteNumber("double.txt", 2.71828);This approach of converting a number to a string ensures our data is stored in a human-readable format. When we read these files back into our program, we can use string parsing functions like std::stoi(), std::stof(), or std::stod() to convert these strings back to their numeric types like int, float and double respectively.
We covered how to read numeric data in more detail in our .
Serializing a Custom Object
Now that we know how to write strings and numbers to files, let's combine these techniques to serialize a simple class. We'll create a Player class that contains both text and numeric data:
src/Player.h
#pragma once
#include <string>
class Player {
public:
std::string Name;
int Level;
float Health;
};We can add serialization capabilities to this class within a SaveToFile() method, using the same techniques we covered earlier in the lesson:
src/Player.h
#pragma once
#include <string>
#include <iostream>
#include <SDL3/SDL.h>
class Player {
public:
std::string Name;
int Level;
float Health;
// Serialize the player to a file
void SaveToFile(const std::string& Path) {
SDL_IOStream* Handle{
SDL_IOFromFile(Path.c_str(), "wb")
};
if (!Handle) {
std::cout << "Error opening file: "
<< SDL_GetError();
return;
}
// Convert numeric values to strings
std::string LevelStr{std::to_string(Level)};
std::string HealthStr{std::to_string(Health)};
// Write each value followed by a comma
SDL_WriteIO(Handle, Name.c_str(), Name.length());
SDL_WriteIO(Handle, ",", 1);
SDL_WriteIO(
Handle, LevelStr.c_str(), LevelStr.length()
);
SDL_WriteIO(Handle, ",", 1);
SDL_WriteIO(
Handle, HealthStr.c_str(), HealthStr.length()
);
SDL_CloseIO(Handle);
}
};We can use this class like so:
Player P1{"Hero", 5, 100.0f};
P1.SaveToFile("player.txt");This will create a text file containing:
Hero,5,100.000000Each piece of data is stored in a human-readable format, separated by commas. This makes the file easy to read and edit manually if needed.
A complete example of this class, including a corresponding LoadFromFile() function using techniques we covered previously, might look like this:
Files
Roderick has been loadedSerialization Libraries
The examples in these sections are focused on slow, manual implementations of serialization and deserialization so we can build a deeper understanding of what is going on.
However, once we understand the low-level details and considerations, it's extremely common to offload that heavy, manual work to a library. Just as SDL makes window management and input handling easier than it otherwise would be, libraries like cereal or boost::serialization exist that make serialization and deserialization easier.
Our Player class has almost 100 lines of manual serialization and deserialization code. However, once we build or adopt a library to standardize how we handle serialization across our project, we could add the exact same capability with just a few lines:
#pragma once
#include <cereal/access.hpp>
#include <cereal/types/string.hpp>
class Player {
public:
std::string Name;
int Level;
float Health;
private:
friend class cereal::access;
template <class Archive>
void serialize(Archive& Data) const {
Data(Name, Level, Health);
}
};Additionally, in larger projects, we have to deal with situations that include:
- Inheritance: How do we serialize objects that are instances of multiple classes?
- Reference Types: How do we serialize objects that include references or pointers to other objects - for example, a
Characterholding aWeapon*member? - Polymorphic Types: If we have a pointer or reference to a base class, how do we serialize that object if it has a more derived type?
- Versioning: When we update our game, how do we ensure saved files based on previous versions are still usable, even if we updated, added, or removed class variables?
Most serialization libraries like Cereal also have mechanisms to take care of these problems for us. We cover Cereal in detail in our .
Summary
In this lesson, we explored writing data to files using SDL3. We learned how to:
- Create and open files using
SDL_IOFromFile() - Write data to files with
SDL_WriteIO() - Understand different file open modes, including binary and text modes
- Append data to existing files
- Handle potential errors during file operations
We also built a complete, round-trip example, in the form of a Player class that can save its current state to disk, and reload itself back to that state later.
These skills form the foundation for more advanced file I/O operations, which we'll build on through the rest of this chapter.
Numeric and Binary Data
Learn how C++ represents numbers and data in memory using binary, decimal, and hexadecimal systems.