C++ Module 4 – Functions, files and scope
C++ command-line arguments and exit codes
Read C++ command-line arguments through argc and argv, report success or failure with exit codes, and read environment variables without crashing.
What you will learn
- Read command-line arguments through argc and argv, and predict what the shell passes
- Convert an argument to a number with std::from_chars and reject text that is not one
- Choose exit statuses from main that scripts can act on, within the 8 bits that survive
- Explain which clean-up std::exit skips that a return from main performs
- Read an environment variable with std::getenv without dereferencing a null pointer
Before you start
What you’ll learn · Before you start
What you will learn
- Read command-line arguments through argc and argv, and predict what the shell passes
- Convert an argument to a number with std::from_chars and reject text that is not one
- Choose exit statuses from main that scripts can act on, within the 8 bits that survive
- Explain which clean-up std::exit skips that a return from main performs
- Read an environment variable with std::getenv without dereferencing a null pointer
Before you start
On this page
Type ./show_args report.txt --top 3, and main receives an argc of 4 and four strings; return 256 from main,
and the shell sees 0. A C++ program talks to whoever started it through three channels: the words of its command
line, the number main returns, and the environment variables it inherits.
You will read arguments and convert them to numbers safely, return exit statuses that scripts can act on, see which
clean-up std::exit skips, and read an environment variable without crashing: one unchecked std::getenv crashed in
two different ways with the two standard libraries. Every program here ran on macOS with Apple clang 21 and GCC 16.
main receives its command line as argc and argv
C++ allows two forms of main: int main(), for programs that take no arguments, and
int main(int argc, char* argv[]). The names argc (argument count) and argv (argument vector) are only
conventions, but everyone uses them. This program prints what it receives, in brackets, so that spaces and empty
arguments show:
// show_args.cpp: prints what the program received on its command line.
#include <print>
int main(int argc, char* argv[]) {
std::println("argc = {}", argc);
for (int i = 0; i < argc; ++i) {
// the brackets show spaces and empty arguments
std::println("argv[{}] = [{}]", i, argv[i]);
}
std::println("argv[argc] is {}", argv[argc] == nullptr ? "a null pointer" : "not null");
} For ./show_args report.txt --top 3, main receives an argc of 4 and an array of five pointers: four to the
command-line argument: A word given to a program when it is started, such as report.txt in ./show_args report.txt. main receives the words, already split, expanded and unquoted by the shell, through argc and argv., the program name included, as C strings, and a last
one that is a null pointer. Step through them:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The count. argc is 4: the program name and the three arguments the shell made of the line. It counts every entry of argv except the last.
- An array of pointers. argv holds the address of an array of five pointers, argv[0] to argv[4]: one per entry, and one more.
- The program name. argv[0] points to the name used to start the program, ./show_args, as a C string: the characters followed by a zero byte.
- The arguments. argv[1] to argv[3] point to report.txt, --top and 3. Every one is text, even 3, which must be converted before it can be used as a number.
- The end marker. argv[4], which is argv[argc], is a null pointer, so a loop can also stop when it meets it. The session printed argv[argc] is a null pointer.
- Valid all run long. The strings keep their values until the program ends, so views of them, such as a std::vector of std::string_view, never dangle.
from session import run
# What main receives: argc counts the program name too, and argv[argc] is a null pointer.
run(r"""
clang++ -std=c++23 -Wall -Wextra show_args.cpp -o show_args
./show_args
./show_args report.txt --top 3
cp show_args renamed && ./renamed
""") Output
$ clang++ -std=c++23 -Wall -Wextra show_args.cpp -o show_args $ ./show_args argc = 1 argv[0] = [./show_args] argv[argc] is a null pointer $ ./show_args report.txt --top 3 argc = 4 argv[0] = [./show_args] argv[1] = [report.txt] argv[2] = [--top] argv[3] = [3] argv[argc] is a null pointer $ cp show_args renamed && ./renamed argc = 1 argv[0] = [./renamed] argv[argc] is a null pointer
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 args_session.py
With no arguments, argc is 1: the program name alone. For ./show_args report.txt --top 3 it is 4, and
argv[1] to argv[3] are the three arguments; --top and 3 are two separate words, because to the program an
option is just an argument that starts with -. argv[argc] is always a null pointer, so code can also walk the
array until it meets it.
The standard only promises that argv[0] represents the name used to start the program, or is an empty string.
After cp show_args renamed, the same program printed ./renamed. Use argv[0] in messages, never to find the
program’s own files.
Checkpoint
Check: argc, argv and argv[0]
The shell splits, expands and unquotes a line before main runs
Between the line you type and argv stands the shell. It splits the line into words, expands variables and
wildcards and removes the quotes, so the program never sees much of what you typed:
from session import run
# The shell splits each line into words, expands variables and wildcards and removes the quotes before the program
# starts. Every line runs in a new sh, so a line that uses $name sets it first; the last two lines ask zsh instead.
run(r"""
clang++ -std=c++23 -Wall -Wextra show_args.cpp -o show_args
./show_args "two words" 'it'\''s' three\ four ""
touch june.csv july.csv
./show_args *.csv
./show_args *.txt
name="Asha Rao"; ./show_args $name "$name"
name="Asha Rao"; ./show_args "two words" *.csv $name
zsh -c 'name="Asha Rao"; ./show_args $name'
zsh -c './show_args *.txt'
""") Output
$ clang++ -std=c++23 -Wall -Wextra show_args.cpp -o show_args $ ./show_args "two words" 'it'\''s' three\ four "" argc = 5 argv[0] = [./show_args] argv[1] = [two words] argv[2] = [it's] argv[3] = [three four] argv[4] = [] argv[argc] is a null pointer $ touch june.csv july.csv $ ./show_args *.csv argc = 3 argv[0] = [./show_args] argv[1] = [july.csv] argv[2] = [june.csv] argv[argc] is a null pointer $ ./show_args *.txt argc = 2 argv[0] = [./show_args] argv[1] = [*.txt] argv[argc] is a null pointer $ name="Asha Rao"; ./show_args $name "$name" argc = 4 argv[0] = [./show_args] argv[1] = [Asha] argv[2] = [Rao] argv[3] = [Asha Rao] argv[argc] is a null pointer $ name="Asha Rao"; ./show_args "two words" *.csv $name argc = 6 argv[0] = [./show_args] argv[1] = [two words] argv[2] = [july.csv] argv[3] = [june.csv] argv[4] = [Asha] argv[5] = [Rao] argv[argc] is a null pointer $ zsh -c 'name="Asha Rao"; ./show_args $name' argc = 2 argv[0] = [./show_args] argv[1] = [Asha Rao] argv[argc] is a null pointer $ zsh -c './show_args *.txt' zsh:1: no matches found: *.txt [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shell_session.py
"two words" arrived as one argument with a space in it, three\ four too, and "" as an empty argument, which is
different from no argument at all. *.csv never reached the program: the shell replaced it with the matching file
names, sorted, while *.txt, which matched no file, was passed on unchanged. And $name without quotes became two
arguments, Asha and Rao, while "$name" stayed one. Step through the last sh line, which does all of it at
once:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The line. name holds Asha Rao, and the folder holds june.csv and july.csv. The shell reads the line ./show_args "two words" *.csv $name before anything runs.
- Four words. The shell splits the line at the spaces that are outside quotes: ./show_args, "two words", *.csv and $name. The space inside the double quotes does not split.
- The variable. $name is replaced by its value, Asha Rao. Variables are expanded first, before any wildcard.
- Split again. The value of an unquoted variable is split at its spaces into fields: Asha and Rao become two words. Written as "$name", it would have stayed one.
- File names. .csv is replaced by the names that match it, sorted: july.csv and june.csv. In sh a pattern that matches no file is passed on as it is, as .txt was.
- No quotes left. Last, the quotes are removed. "two words" arrives as two words with a space in it; the program never sees a quote character.
- What main gets. The program starts with argc 6, and argv[0] to argv[5] are ./show_args, two words, july.csv, june.csv, Asha and Rao, as the session printed.
- zsh differs. zsh, the default shell on macOS, skips the split of an unquoted variable unless its SH_WORD_SPLIT option is set: the first zsh line of the session gave argc 2 and Asha Rao in one piece.
POSIX fixes this order for sh, and bash follows it: variables first, then the splitting of what they produced,
then file names, and quote removal last. zsh, the default shell on macOS, differs in two details the last lines of
the session show: it does not split an unquoted variable unless its SH_WORD_SPLIT option is set, and it refuses to
run a command whose pattern matches no file. Quote variables in scripts, and every shell passes them on in one piece.
Windows works differently. There a program receives one command-line string, and the startup code of the Microsoft C
runtime splits it into argv with its own rules for double quotes and backslashes; it does not expand * at all
unless the program is linked with setargv.obj. A tool meant for both systems should not depend on wildcards having
been expanded.
Key idea
Splitting, quoting and wildcards are the shell’s work, done before main runs: the program sees only the finished
words, and the same line can give different words in another shell.
Checkpoint
Check: What the shell does to a command line
Copy the arguments into string views, then convert each number
argv is an array of char* from C. The convenient way to handle it in C++ is to copy the arguments once into a
std::vector<std::string_view>, a growable list of string views (vectors get their own lesson in the next module).
This program checks that every argument is a mark from 0 to 100:
// check_marks.cpp: checks that every argument is a mark from 0 to 100.
// Exit status: 0 all marks valid, 1 at least one is not, 2 the command was used wrongly.
#include <charconv>
#include <cstdlib>
#include <optional>
#include <print>
#include <string_view>
#include <vector>
constexpr int exit_invalid_mark = 1;
constexpr int exit_usage = 2;
// The whole text as an int, or no value when it is not one (std::optional: a later lesson).
std::optional<int> to_int(std::string_view text) {
int value = 0;
auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value);
if (error != std::errc{} || end != text.data() + text.size()) return std::nullopt;
return value;
}
void print_usage(std::FILE* stream) {
std::println(stream, "usage: check_marks MARK... (each MARK a whole number from 0 to 100)");
}
int main(int argc, char* argv[]) {
// the arguments after the program name
std::vector<std::string_view> args(argv + 1, argv + argc);
if (args.empty()) {
print_usage(stderr);
return exit_usage;
}
if (args[0] == "--help") {
print_usage(stdout);
return EXIT_SUCCESS;
}
int invalid = 0;
for (std::string_view arg : args) {
std::optional<int> mark = to_int(arg);
if (!mark || *mark < 0 || *mark > 100) {
std::println(stderr, "check_marks: '{}' is not a mark from 0 to 100", arg);
++invalid;
}
}
if (invalid > 0) return exit_invalid_mark;
std::println("all {} marks are valid", args.size());
return EXIT_SUCCESS;
} std::vector<std::string_view> args(argv + 1, argv + argc); makes one view for each argument after the program
name. The strings that argv points to keep their values until the program ends, so these views never dangle, and
they bring ==, starts_with and size() to plain C strings. Arguments are always text: "72" is two characters,
not a number. to_int converts with std::from_chars from <charconv>, which reports an error code and where the
number ended. This program shows what it reported for six arguments:
// from_chars_demo.cpp: what std::from_chars reports for six arguments, and what to_int makes of each.
#include <charconv>
#include <format>
#include <optional>
#include <print>
#include <string>
#include <string_view>
#include <system_error>
// The whole text as an int, or no value when it is not one (as in check_marks.cpp).
std::optional<int> to_int(std::string_view text) {
int value = 0;
auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value);
if (error != std::errc{} || end != text.data() + text.size()) return std::nullopt;
return value;
}
// What from_chars reported for the text, then what to_int returns.
void explain(std::string_view text) {
int value = 0;
auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value);
std::optional<int> result = to_int(text);
std::string verdict = result ? std::format("to_int gives {}", *result) : "to_int gives no value";
if (error == std::errc::invalid_argument)
std::println("[{}] invalid_argument: {}", text, verdict);
else if (error == std::errc::result_out_of_range)
std::println("[{}] result_out_of_range: {}", text, verdict);
else
std::println("[{}] value {}, read {} of {} characters: {}", text, value, end - text.data(), text.size(), verdict);
}
int main() {
for (std::string_view text : {"72", "-4", "12abc", "abc", "", "99999999999"}) explain(text);
} Output
[72] value 72, read 2 of 2 characters: to_int gives 72 [-4] value -4, read 2 of 2 characters: to_int gives -4 [12abc] value 12, read 2 of 5 characters: to_int gives no value [abc] invalid_argument: to_int gives no value [] invalid_argument: to_int gives no value [99999999999] result_out_of_range: to_int gives no value
Recorded with Apple clang version 21.0.0 (clang-2100.3.34.2) on macOS 26 arm64. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined from_chars_demo.cpp -o from_chars_demo && ./from_chars_demo
For 12abc, from_chars read the 12 and stopped at the a, which is why to_int also checks that the number used
the whole text: half-read input is rejected, never taken as 12. An empty argument and abc gave invalid_argument,
and 99,999,999,999, too large for an int, gave result_out_of_range. to_int returns a std::optional<int>, a
value that holds either an int or nothing; a later module covers it. -4 is a number, so to_int accepts it, and
check_marks rejects it afterwards with its range test.
Two more habits of check_marks are worth copying. Messages about problems go to
standard streams: The three channels a console program starts with: standard input, which std::cin reads, and standard output and standard error, which std::cout and std::cerr write. The shell can connect each one to the terminal, a file or a pipe. More in “Console I/O in C++: cin, cout, print and println” with std::println(stderr, …), and only the real result goes to
standard output, so check_marks 72 abc > results.txt still shows the complaint on the screen and keeps it out of
the file. And the usage line goes to standard output when someone asks for it with --help, because then it is the
requested output, and to standard error after a mistake.
Checkpoint
Check: Converting arguments to numbers
The value main returns is the exit status scripts read
A return statement in main leaves main and then calls std::exit with the returned value, and reaching the
closing brace of main is the same as return 0;. The standard defines only three values: 0 and EXIT_SUCCESS mean
success, and EXIT_FAILURE means failure (both macros are in <cstdlib>; on macOS, Linux and Windows they are 0 and
1). Every other value means whatever the program documents. The shell keeps the last
exit status: The number a program hands back when it ends, the value main returns. 0 means success; a shell keeps the last status in $?, and && runs the next command only after a status of 0. More in “How C++ compiles: preprocess, compile and link” in $?, and &&, || and if choose what to run next from it:
#!/bin/sh
# save_marks.sh: decides what to do from the exit status of check_marks.
if ./check_marks "$@"; then
echo "save_marks: saved $# marks"
else
status=$?
echo "save_marks: check_marks failed with status $status, nothing saved"
exit "$status"
fi from session import run
run(r"""
clang++ -std=c++23 -Wall -Wextra check_marks.cpp -o check_marks
./check_marks 72 91 58; echo "exit status: $?"
./check_marks 72 abc 101 -4; echo "exit status: $?"
./check_marks; echo "exit status: $?"
./check_marks --help; echo "exit status: $?"
./check_marks 72 abc > /dev/null; echo "only standard error was left on the screen, exit status: $?"
./check_marks 72 91 && echo "the && part runs after a success"
./check_marks 72 910 || echo "the || part runs after a failure"
sh save_marks.sh 64 77
sh save_marks.sh 64 seventy
""") Output
$ clang++ -std=c++23 -Wall -Wextra check_marks.cpp -o check_marks $ ./check_marks 72 91 58; echo "exit status: $?" all 3 marks are valid exit status: 0 $ ./check_marks 72 abc 101 -4; echo "exit status: $?" check_marks: 'abc' is not a mark from 0 to 100 check_marks: '101' is not a mark from 0 to 100 check_marks: '-4' is not a mark from 0 to 100 exit status: 1 $ ./check_marks; echo "exit status: $?" usage: check_marks MARK... (each MARK a whole number from 0 to 100) exit status: 2 $ ./check_marks --help; echo "exit status: $?" usage: check_marks MARK... (each MARK a whole number from 0 to 100) exit status: 0 $ ./check_marks 72 abc > /dev/null; echo "only standard error was left on the screen, exit status: $?" check_marks: 'abc' is not a mark from 0 to 100 only standard error was left on the screen, exit status: 1 $ ./check_marks 72 91 && echo "the && part runs after a success" all 2 marks are valid the && part runs after a success $ ./check_marks 72 910 || echo "the || part runs after a failure" check_marks: '910' is not a mark from 0 to 100 the || part runs after a failure $ sh save_marks.sh 64 77 all 2 marks are valid save_marks: saved 2 marks $ sh save_marks.sh 64 seventy check_marks: 'seventy' is not a mark from 0 to 100 save_marks: check_marks failed with status 1, nothing saved [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_session.py
check_marks documents three exit statuses: 0 when every mark is valid, 1 when at least one is not, and 2 when it
was used wrongly. save_marks.sh never reads what check_marks prints, only its status. Step through its last run:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The script calls the tool. The if line runs ./check_marks "$@". The quoted "$@" passes on the script's own arguments unchanged: 64 and seventy.
- One bad argument. 64 is a whole number from 0 to 100. seventy is not a number at all, so check_marks prints its complaint on standard error and counts one invalid mark.
- Status 1 comes back. check_marks returns exit_invalid_mark, 1, from main. The script receives that number and nothing else: it never reads the output of check_marks.
- The else branch. if counts any status other than 0 as false, so the else branch runs. status=$? keeps the 1, and the script prints that nothing was saved.
- Passing it on. exit "$status" ends the script with the same status, so whatever ran the script learns of the failure too: the session shows [exit status 1].
- The good run. With 64 77, check_marks returned 0, the if branch ran and the script printed save_marks: saved 2 marks.
Build tools, CI jobs and test runners use your program in exactly this way. Some values mean the same thing to many programs:
| Status | What it usually means |
|---|---|
| 0 | Success: EXIT_SUCCESS |
| 1 | A general failure: EXIT_FAILURE here |
| 2 | Used wrongly, as Bash says of its builtins |
| 126 and 127 | The shell’s own: not executable, not found |
| 128 + n | Stopped by signal n, in bash and zsh |
A status other than 0 is not always an error, though: GNU grep exits with 1 when no line matched and with 2 when
something went wrong. BSD systems also define named codes from 64 up in <sysexits.h> (EX_USAGE is 64), but
FreeBSD’s own manual calls that interface deprecated and discourages its use. So pick small values from 0 to 125,
give a failure its own value only when a caller can do something different with it, and list the values in the
program’s help.
Checkpoint
Check: Exit statuses that scripts read
Only the lowest 8 bits of the status reach the shell
main returns an int, but a parent process on macOS or Linux receives less. This program returns whatever number
it is given:
// return_code.cpp: returns the number it is given from main; what does the shell see?
#include <charconv>
#include <string_view>
int main(int argc, char* argv[]) {
if (argc != 2) return 2;
std::string_view text = argv[1];
int code = 0;
std::from_chars(text.data(), text.data() + text.size(), code);
return code;
} from session import run
# The shell keeps only the lowest 8 bits of the value: what main returned, modulo 256.
run(r"""
clang++ -std=c++23 -Wall -Wextra return_code.cpp -o return_code
for code in 0 1 42 255 256 257 300 1000 -1; do ./return_code $code; echo "main returned $code, the shell saw $?"; done
""") Output
$ clang++ -std=c++23 -Wall -Wextra return_code.cpp -o return_code $ for code in 0 1 42 255 256 257 300 1000 -1; do ./return_code $code; echo "main returned $code, the shell saw $?"; done main returned 0, the shell saw 0 main returned 1, the shell saw 1 main returned 42, the shell saw 42 main returned 255, the shell saw 255 main returned 256, the shell saw 0 main returned 257, the shell saw 1 main returned 300, the shell saw 44 main returned 1000, the shell saw 232 main returned -1, the shell saw 255
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 return_code_session.py
POSIX lets a parent read only the lowest 8 bits of the status through wait() and waitpid(), the calls a shell
uses: the value modulo 256, the remainder after dividing by 256. A program that returns 256 to say “256 errors”
therefore reports success, 300 arrives as 44, 1000 = 3 × 256 + 232 as 232, and -1 as 255. Windows keeps the whole
32-bit value, which GetExitCodeProcess reports, so the same program would show 256 there.
Values from 126 up have a second problem even when they fit: they collide with what the shell itself reports for a command that cannot run and for a program stopped by a signal. Keep exit codes between 0 and 125, and neither question comes up.
Programmer Calculator (Bitwise) Work out what the lowest 8 bits of a returned value are: 1000 is 0x3E8, so the shell sees 0xE8, which is 232.Checkpoint
Check: The 8 bits of an exit status
Returning from main destroys local objects; std::exit skips them
std::exit(status) from <cstdlib> ends the program from anywhere, even deep inside other functions. It does less
clean-up than a return from main. In this program a Notice prints a line when it is destroyed:
// exit_or_return.cpp: what is cleaned up when main returns, and what is not when
// std::exit ends the program.
#include <cstdlib>
#include <print>
#include <string_view>
// A Notice prints a line when it is destroyed, so the session shows which clean-up ran.
struct Notice {
std::string_view name;
~Notice() { std::println(" {} cleaned up", name); }
};
Notice log_file{"global log file"}; // lives for the whole program
int finish(bool use_exit) {
Notice temp_file{"local temp file"}; // lives until finish returns
if (use_exit) std::exit(EXIT_FAILURE);
return EXIT_FAILURE;
}
int main(int argc, char* argv[]) {
bool use_exit = argc > 1 && std::string_view{argv[1]} == "--exit";
std::println("ending with {}:", use_exit ? "std::exit" : "return");
return finish(use_exit);
} from session import run
run(r"""
clang++ -std=c++23 -Wall -Wextra exit_or_return.cpp -o exit_or_return
./exit_or_return
./exit_or_return --exit
g++-16 -std=c++23 -Wall -Wextra exit_or_return.cpp -o exit_or_return
./exit_or_return
./exit_or_return --exit
""") Output
$ clang++ -std=c++23 -Wall -Wextra exit_or_return.cpp -o exit_or_return $ ./exit_or_return ending with return: local temp file cleaned up global log file cleaned up [exit status 1] $ ./exit_or_return --exit ending with std::exit: global log file cleaned up [exit status 1] $ g++-16 -std=c++23 -Wall -Wextra exit_or_return.cpp -o exit_or_return $ ./exit_or_return ending with return: local temp file cleaned up global log file cleaned up [exit status 1] $ ./exit_or_return --exit ending with std::exit: global log file cleaned up [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exit_or_return_session.py
Step through the two runs and watch which objects are destroyed:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A local object. Both runs print the line ending with …, then main calls finish, which creates the local object temp_file. It lives until finish is left.
- finish returns. Without --exit, finish returns EXIT_FAILURE. Leaving finish destroys temp_file, which prints local temp file cleaned up.
- main returns. main returns that 1. Returning from main leaves main and then calls std::exit with the value, which destroys the global log_file: global log file cleaned up.
- The other run. With --exit, finish calls std::exit(EXIT_FAILURE) while temp_file still exists. The program ends without leaving finish, and no local object is destroyed.
- Globals still go. std::exit still destroys the global log_file and flushes standard output, so the run printed only global log file cleaned up.
- The same status. Both ways the shell saw status 1, and the Apple clang and GCC builds printed the same lines. Only the clean-up differed.
When finish returns and main returns, the local object temp_file is destroyed on the way out, and then the
global log_file. With std::exit, the global object is still destroyed, but temp_file is not: the standard says
that ending a program without leaving the current block destroys no local objects. Both compilers behaved the same.
Here a skipped clean-up only means a missing line, but in real programs local objects close files, finish writes
and remove temporary files. So return an error from function to function until main can return it; the
error-handling module shows exceptions, which do the same job without the manual passing on. std::exit still
flushes standard output, so text printed before it is not lost.
Checkpoint
Check: What std::exit cleans up
std::getenv returns a null pointer for a variable that is not set
environment variable: A name and value pair, such as HOME or PATH, that a program inherits from the process that started it. std::getenv reads one by name and returns a null pointer when it is not set. are name and value pairs that a program inherits from
whoever started it: HOME, PATH, or settings such as a folder for reports. std::getenv("REPORT_DIR") gives back a
pointer to the variable’s text, and a null pointer if no such variable exists. Code that forgets the second case
crashes, and the two standard libraries crash differently:
C++ (getenv_wrong.cpp) · getenv_wrong.cpp
// getenv_wrong.cpp: assumes that REPORT_DIR is always set. It is not.
#include <cstdlib>
#include <print>
#include <string>
int main() {
// a null pointer when the variable is not set
std::string dir = std::getenv("REPORT_DIR");
std::println("reports go to {}", dir);
} C++ (getenv_right.cpp) · getenv_right.cpp
// getenv_right.cpp: checks the pointer before using it, and falls back to a default.
#include <cstdlib>
#include <print>
#include <string_view>
std::string_view report_dir() {
const char* value = std::getenv("REPORT_DIR");
// not set, or set to an empty string
if (value == nullptr || *value == '\0') return ".";
return value;
}
int main() {
std::println("reports go to {}", report_dir());
} from session import run
# env -u removes a variable for one command; NAME=value before a command sets it for that command only.
run(r"""
clang++ -std=c++23 -Wall -Wextra getenv_wrong.cpp -o wrong_clang
g++-16 -std=c++23 -Wall -Wextra getenv_wrong.cpp -o wrong_gcc
REPORT_DIR=/srv/reports ./wrong_clang
env -u REPORT_DIR ./wrong_clang
env -u REPORT_DIR ./wrong_gcc
clang++ -std=c++23 -Wall -Wextra getenv_right.cpp -o right
env -u REPORT_DIR ./right
REPORT_DIR= ./right
REPORT_DIR=/srv/reports ./right
""") Output
$ clang++ -std=c++23 -Wall -Wextra getenv_wrong.cpp -o wrong_clang $ g++-16 -std=c++23 -Wall -Wextra getenv_wrong.cpp -o wrong_gcc $ REPORT_DIR=/srv/reports ./wrong_clang reports go to /srv/reports $ env -u REPORT_DIR ./wrong_clang [stopped by signal 11 (SIGSEGV): a shell reports exit status 139] $ env -u REPORT_DIR ./wrong_gcc terminate called after throwing an instance of 'std::logic_error' what(): basic_string: construction from null is not valid [stopped by signal 6 (SIGABRT): a shell reports exit status 134] $ clang++ -std=c++23 -Wall -Wextra getenv_right.cpp -o right $ env -u REPORT_DIR ./right reports go to . $ REPORT_DIR= ./right reports go to . $ REPORT_DIR=/srv/reports ./right reports go to /srv/reports
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 getenv_session.py
REPORT_DIR |
getenv_wrong.cpp |
getenv_right.cpp |
|---|---|---|
/srv/reports |
reports go to /srv/reports | reports go to /srv/reports |
| Not set | SIGSEGV with libc++, SIGABRT with libstdc++ |
reports go to . |
While REPORT_DIR is set, the wrong version works, which is how this bug survives testing. Without it,
std::string dir = std::getenv("REPORT_DIR"); builds a string from a null pointer, which is undefined behaviour: the
Apple clang build with libc++ stopped with a segmentation fault (exit status 139), and the GCC build with libstdc++
noticed the null pointer, threw std::logic_error and stopped with SIGABRT (134). The right version checks the
pointer, treats an empty value like a missing one, and falls back to a default, as its run with REPORT_DIR= shows.
Building a std::string straight from std::getenv
std::string dir = std::getenv("REPORT_DIR"); crashes whenever the variable is missing. Keep the pointer in a
const char*, test it against nullptr (and for an empty value), and only then use it.
report_dir returns a std::string_view, which suits a value that is read and used at once; to keep a value for
later, copy it into a std::string, because a later change to the environment can make the pointer invalid. Two
more rules come from the standards: never modify the characters that getenv returns, and do not change the
environment (with the POSIX function setenv) while other threads may call getenv, because the C++ standard
promises no data race only as long as nothing modifies the environment. Reading the settings once at start-up, in
main, avoids both problems.
Checkpoint
Check: Reading environment variables
Practise: a divide command
Write divide DIVIDEND DIVISOR, a command that prints the quotient of two whole numbers with two decimals, so
./divide 7 2 prints 3.50. It must report every problem on standard error, print nothing on standard output, and
exit with its own status:
- 0: success, the quotient printed.
- 2: not exactly two arguments.
- 3: an argument that is not a whole number that fits in an
int, such asseven,2x, an empty argument or99999999999. - 4: a divisor of 0.
divide.cpp (starter) · exercise/divide.cpp
// divide.cpp: the starter. It divides correctly when given two whole numbers and a
// divisor that is not 0, and checks nothing else yet: add the checks and exit statuses.
#include <charconv>
#include <optional>
#include <print>
#include <string_view>
#include <vector>
// The whole text as an int, or no value when it is not one (as in check_marks.cpp).
std::optional<int> to_int(std::string_view text) {
int value = 0;
auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value);
if (error != std::errc{} || end != text.data() + text.size()) return std::nullopt;
return value;
}
int main(int argc, char* argv[]) {
std::vector<std::string_view> args(argv + 1, argv + argc);
int dividend = args.size() > 0 ? to_int(args[0]).value_or(0) : 0;
int divisor = args.size() > 1 ? to_int(args[1]).value_or(0) : 0;
std::println("{:.2f}", static_cast<double>(dividend) / divisor);
} exercise/divide_tests.sh
#!/bin/sh
# divide_tests.sh: the public tests of the divide exercise. Run it with sh in the folder that holds ./divide.
# Each test gives the exit status, the standard output and whether a message on standard error is expected.
passed=0
failed=0
expect() {
want_status=$1
want_out=$2
want_message=$3
shift 3
out=$(./divide "$@" 2> errors.txt)
status=$?
message=$(cat errors.txt)
ok=yes
[ "$status" = "$want_status" ] || ok=no
[ "$out" = "$want_out" ] || ok=no
if [ "$want_message" = message ] && [ -z "$message" ]; then ok=no; fi
if [ "$want_message" = silent ] && [ -n "$message" ]; then ok=no; fi
if [ "$ok" = yes ]; then
passed=$((passed + 1))
else
failed=$((failed + 1))
echo "FAIL ./divide $*: wanted status $want_status and '$want_out', got status $status and '$out'"
fi
}
expect 0 "3.50" silent 7 2
expect 0 "-2.50" silent -5 2
expect 0 "0.00" silent 0 9
expect 2 "" message
expect 2 "" message 7
expect 2 "" message 7 2 1
expect 3 "" message seven 2
expect 3 "" message 7 2x
expect 3 "" message 7 ""
expect 3 "" message 99999999999 1
expect 4 "" message 7 0
echo "$passed passed, $failed failed"
[ "$failed" -eq 0 ] Save both files in one folder, build divide from your divide.cpp, and run sh divide_tests.sh; it ends with
11 passed, 0 failed and exit status 0 when your program is right. The starter divides correctly but checks nothing,
so it fails 8 of the 11 tests:
from session import run
# The public tests, run against the starter file.
run(r"""
cd exercise && clang++ -std=c++23 -Wall -Wextra divide.cpp -o divide && sh divide_tests.sh
""") Output
$ cd exercise && clang++ -std=c++23 -Wall -Wextra divide.cpp -o divide && sh divide_tests.sh FAIL ./divide : wanted status 2 and '', got status 0 and 'nan' FAIL ./divide 7: wanted status 2 and '', got status 0 and 'inf' FAIL ./divide 7 2 1: wanted status 2 and '', got status 0 and '3.50' FAIL ./divide seven 2: wanted status 3 and '', got status 0 and '0.00' FAIL ./divide 7 2x: wanted status 3 and '', got status 0 and 'inf' FAIL ./divide 7 : wanted status 3 and '', got status 0 and 'inf' FAIL ./divide 99999999999 1: wanted status 3 and '', got status 0 and '0.00' FAIL ./divide 7 0: wanted status 4 and '', got status 0 and 'inf' 3 passed, 8 failed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 divide_session.py
A first hint: check the number of arguments first, before you look at args[0] or args[1], then convert both
with to_int, and test the divisor last. Give the exit statuses names, such as constexpr int exit_usage = 2;, as
check_marks.cpp does.
When the tests pass, see how well the seven ideas of this lesson have stuck. The questions begin where your answers have been weakest, and every choice comes with its reason:
Practice that adapts to you
Practise this lesson
14 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What do argc and argv hold, and what is argv[argc]?
argc is the number of entries in argv, counting the program name, so a program run with two arguments sees
argc == 3. argv is an array of pointers to C strings: argv[0] represents the name used to start the program (or
is an empty string), argv[1] to argv[argc - 1] are the arguments, already split and unquoted by the shell, and
argv[argc] is guaranteed to be a null pointer, so code can also walk the array until it meets the null pointer. The
strings stay valid for the whole run, and they are always text: a number has to be converted, for example with
std::from_chars.
Interviewers tend to follow this one with questions about quoting and about Windows. Answer both, then read why each choice holds or fails:
Checkpoint
Interview round: two follow-up questions
Key takeaways
int main(int argc, char* argv[]):argccounts the program name and the arguments,argv[0]is the name used to start the program, andargv[argc]is a null pointer.- The shell splits words, expands variables and
*and removes quotes beforemainruns; Windows splits the command line in the program’s startup code instead and expands no wildcards by default. - Copy the arguments into a
std::vector<std::string_view>once, convert numbers withstd::from_charsand check that the whole text was used, and send complaints to standard error. - The value
mainreturns is the exit status: 0 for success and small documented values for each failure. POSIX systems keep only the lowest 8 bits, so 256 reads as 0. - Returning from
maindestroys local objects;std::exitskips them, though it still destroys globals and flushes the output. std::getenvreturns a null pointer for a variable that is not set: check it before using the value.
Terms in this lesson
- command-line argument
- A word given to a program when it is started, such as report.txt in ./show_args report.txt. main receives the words, already split, expanded and unquoted by the shell, through argc and argv.
- environment variable
- A name and value pair, such as HOME or PATH, that a program inherits from the process that started it. std::getenv reads one by name and returns a null pointer when it is not set.
References
- Working Draft, Programming Languages: C++ (N4950): 6.9.3.1 main function, 17.5 Startup and termination, 17.13 Other runtime support, 22.13 Primitive numeric conversions (ISO/IEC JTC1/SC22/WG21)
- Working Draft N3220, Programming languages: C (program startup in a hosted environment) (ISO/IEC JTC1/SC22/WG14)
- POSIX.1-2024: exit, _Exit, _exit (The Open Group and IEEE)
- POSIX.1-2024: getenv (The Open Group and IEEE)
- POSIX.1-2024: Shell Command Language (2.6 Word Expansions, 2.8.2 Exit Status for Commands) (The Open Group and IEEE)
- Bash Reference Manual: Exit Status (GNU Project)
- Z Shell Manual: Options (SH_WORD_SPLIT) (The Zsh Development Group)
- GNU grep manual: Exit Status (GNU Project)
- sysexits(3): preferable exit codes for programs (The FreeBSD Project)
- main function and command-line arguments (C++) (Microsoft)
- Expanding wildcard arguments (Microsoft)
- GetExitCodeProcess function (processthreadsapi.h) (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress