Information and Computer Science 45C — Week 3 Reading Notes
Table of Contents
- 1. Introduction to std::string
- 2. Introduction to std::string_view
- 3. Return by Reference and Return by Address
- 4. Default Constructors and Default Arguments
- 5. Introduction to C-style Arrays
- 6. C-style Array Decay
- 7. Pointer Arithmetic and Subscripting
- 8. C-style Strings
- 9. Introduction to Operator Overloading
- 10. Overloading the I/O Operators
- 11. Overloading Operators Using Member Functions
- 11B. Object Lifetime Walkthrough — Lecture Supplement
- 12. Basic File I/O
- 13. Code Coverage (Testing)
- 14. Common Semantic Errors in C++
- 15. Detecting and Handling Errors
- 16. Custom String Class — Lecture Supplement
- 17. The static Keyword
- 18. Quick Reference Summary
UCI ICS 45C: Programming in C++ (35770)
These notes integrate Week 3 Assigned Reading (LearnCpp chapters) with lecture slides (Classes, Strings), organized in study order.
1. Introduction to std::string
Corresponds to LearnCpp 5.7 — Introduction to std::string
1.1 Why std::string Over C-style Strings?
C-style string variables (char arrays) are hard to work with: you can't use = to assign a new value, and copying a string that's too long causes undefined behavior. std::string handles all of this automatically.
#include <string>
std::string name{"Alex"}; // initialize
name = "John"; // reassign — works fine!
name = "A much longer name than before"; // also fine — auto-resizesPython comparison: std::string behaves much like Python's str, except that C++ strings are mutable (you can change individual characters).
1.2 String Input: >> vs getline()
std::cin >> str reads only one word (stops at whitespace). To read an entire line, use std::getline():
std::string name{};
std::getline(std::cin >> std::ws, name); // reads full line, skips leading whitespace⚠️ The
std::wstrap: If you mixcin >>andgetline(), a leftover'\n'in the input buffer will causegetlineto read an empty string. Always usestd::cin >> std::wsas the first argument togetlineto consume leading whitespace.
int choice{};
std::cin >> choice; // leaves '\n' in buffer
std::string name{};
std::getline(std::cin >> std::ws, name); // std::ws eats the leftover '\n'1.3 Deep Dive: std::ws
std::ws is an I/O manipulator — same family as std::endl, std::setw, and std::setprecision. It lives in <istream> (usually pulled in via <iostream>).
What it does: When inserted into an input stream, it consumes leading whitespace characters (spaces, tabs, newlines) until it hits a non-whitespace character or EOF. That's it — it does not skip middle or trailing whitespace.
std::cin >> std::ws; // discard whatever whitespace is at the front of the bufferWhy do we need it specifically for getline?
The >> operator has a flag called std::skipws that's on by default for input streams. That's why this just works without thinking:
int a, b;
std::cin >> a >> b; // " 42 7 " → a=42, b=7 (skipws auto-handles whitespace)But std::getline() is different — it does not consult skipws. It reads everything up to (and including) the first '\n', so any leftover '\n' in the buffer immediately terminates it with an empty string:
int age;
std::string name;
std::cin >> age; // reads 42, leaves '\n' in buffer
std::getline(std::cin, name); // reads empty string (just the stray '\n')! ❌Putting std::cin >> std::ws as the first argument to getline eats that leftover '\n' (and any other whitespace) before reading:
std::cin >> age;
std::getline(std::cin >> std::ws, name); // ✅ eats '\n' first, then reads the real nameKey takeaway: Only use std::ws with getline (or other input operations that don't auto-skip whitespace). For >> reading into primitives or strings, it's redundant.
| Input operation | Needs std::ws? |
Why |
|---|---|---|
cin >> int_var |
❌ | skipws flag is on by default |
cin >> string_var |
❌ | operator>> skips leading whitespace automatically |
cin >> char_var |
❌ | Same |
getline(cin, str) |
✅ (when mixed with >>) |
getline does NOT skip whitespace |
cin.get() / cin.get(ch) |
✅ (if you want to skip) | These read raw characters, including whitespace |
⚠️ Caveat:
std::wsonly skips leading whitespace. If you want to read" hello "and trim both ends,std::wsonly handles the front — you'd need to trim the back yourself (e.g., erase trailing spaces after reading).
💡 Python comparison:
std::cin >> std::wsis roughly analogous to calling.lstrip()on the input stream — it strips leading whitespace but leaves everything else alone.
1.4 Useful std::string Operations
std::string s{"Hello"};
s.size(); // 5 (same as s.length())
s[0]; // 'H'
s[0] = 'J'; // s is now "Jello"
s + " World"; // concatenation → "Jello World"
s.empty(); // false💡
std::stringuses dynamic memory allocation internally. It can grow and shrink as needed, but this makes it slower than fixed-size C-style strings for some use cases.
1.5 std::string Literals (the s Suffix)
Double-quoted string literals like "hello" are C-style strings by default — their type is const char*, not std::string. To create an actual std::string literal, append an s suffix (the s must be lowercase):
#include <iostream>
#include <string>
int main() {
using namespace std::string_literals; // bring in the `s` suffix
std::cout << "goo\n"; // C-style literal (const char*)
std::cout << "foo\n"s; // std::string literal
std::cout << "moo\n"s; // std::string literal
}The s suffix lives in the namespace std::literals::string_literals. LearnCpp's recommendation: bring it in with using namespace std::string_literals; inside the function where you need it, not at file scope — since using namespace in a header or at top-level can leak identifiers into places you didn't intend.
When does this actually matter?
Most of the time you don't need it, because initialization converts automatically:
std::string name{"Alex"}; // no suffix needed — constructor converts const char* → std::string
void foo(std::string s);
foo("hello"); // also fine — implicit conversionBut the s suffix is required when the compiler needs to deduce std::string rather than const char*:
auto s1 = "hello"; // ⚠️ type is const char*, NOT std::string
auto s2 = "hello"s; // ✅ type is std::stringIt also comes up in templated code where the template argument must already be std::string before deduction happens.
💡 For advanced readers:
"hello"sis implemented roughly asstd::string{"hello", 5}— it constructs a temporarystd::stringfrom the underlying C-style literal (length 5, excluding the null terminator).
💡 Parallel for std::string_view: There's also a
svsuffix instd::literals::string_view_literalsthat creates astd::string_viewliteral:auto sv = "hello"sv;. Same idea ass, same recommendation — prefer function-scopedusing namespace std::string_view_literals;.
2. Introduction to std::string_view
Corresponds to LearnCpp 5.8 — Introduction to std::string_view
2.1 The Problem: std::string Copies Are Expensive
void printString(std::string s) { // ⚠️ makes a copy every call!
std::cout << s;
}
std::string name{"A very long name..."};
printString(name); // copy #1
printString(name); // copy #2 — wasted workEvery call copies the entire string's contents. For short strings this is cheap; for long ones it's a real cost you pay on every call.
2.2 std::string_view: A Non-owning View
C++17 introduced std::string_view in <string_view>. It's a read-only view into an existing string — internally it stores just a pointer and a length, with no data of its own.
#include <string_view>
void printString(std::string_view s) { // cheap — no copy
std::cout << s;
}
std::string name{"A very long name..."};
printString(name); // ✅ view into name
printString("literal"); // ✅ view into string literal
printString(std::string{"temporary"}); // ✅ view into temporary std::stringA string_view is flexible: it can be initialized from a std::string, a C-style string (const char*), or a string literal.
Python comparison: std::string_view is like a Python slice s[start:end] — it borrows a window into existing data rather than copying.
2.3 Key Point: Non-owning
string_view does NOT own the data it points to. The underlying string must outlive the view:
std::string_view getView() {
std::string s{"Hello"}; // local std::string
return s; // ❌ DANGLING VIEW — s is destroyed when function returns
}std::string_view sv;
{
std::string s{"Hello"};
sv = s; // sv views s
} // s is destroyed here
std::cout << sv; // ❌ dangling — undefined behavior⚠️ Rule of thumb: Never store a
string_viewas a class member or return one from a function unless you're certain the underlying data outlives the view.
2.4 Pass by Value, Not by Reference
std::string_view is tiny (typically 16 bytes: a pointer + a length). Pass it by value, not by reference:
void good(std::string_view s); // ✅ pass by value
void bad(const std::string_view& s); // ❌ unnecessary — nothing to avoid copying2.5 Common Operations
std::string_view s{"Hello World"};
s.size(); // 11
s.length(); // 11 (same as size)
s[0]; // 'H'
s.substr(6, 5); // "World" — still a view, no copy
s.remove_prefix(6); // now views "World"
s.remove_suffix(1); // now views "Worl"
s == "Worl"; // true💡
.remove_prefix()and.remove_suffix()shrink the view by advancing/retracting its bounds. They don't modify the underlying data.
2.6 When to Use What
| Use case | Preferred type |
|---|---|
| Function parameter (read-only) | std::string_view |
| Member variable (owns data) | std::string |
| Return value (you created the string) | std::string |
| Return value (view into existing data) | std::string_view ⚠️ careful about lifetime |
Temporary concatenation (s1 + s2) |
std::string (string_view can't concatenate) |
💡 Modern C++ rule: Prefer
std::string_viewfor read-only string parameters. Usestd::stringonly when you need ownership or mutation.
2.7 Gotcha: No Guaranteed Null Termination
A std::string_view is just "pointer + length" — it does not guarantee the viewed data is null-terminated. So you cannot safely pass sv.data() to C APIs that expect a const char*:
std::string_view sv{"Hello World"};
auto sub = sv.substr(0, 5); // views "Hello"
printf("%s\n", sub.data()); // ❌ prints "Hello World" — no '\0' at position 5!If you need a null-terminated string for a C API, convert back to std::string:
std::string s{sub};
printf("%s\n", s.c_str()); // ✅ s.c_str() is guaranteed null-terminated⚠️ Remember:
std::string::c_str()is null-terminated;std::string_view::data()is not.
3. Return by Reference and Return by Address
Corresponds to LearnCpp 12.12 — Return by reference and return by address
3.1 Why Return by Reference?
Returning a class type by value makes a copy, which can be expensive. Return by reference avoids the copy:
const std::string& getProgramName() {
static const std::string s_programName{"Calculator"};
return s_programName; // returns a reference — no copy
}3.2 The Dangling Reference Danger
The object being returned must outlive the function. Returning a reference to a local variable is undefined behavior:
const std::string& getProgramName() {
const std::string name{"Calculator"}; // local variable
return name; // ❌ DANGLING REFERENCE — name is destroyed when function ends
}⚠️ Rule: Never return a non-static local variable or temporary by reference. The compiler usually warns about this, but not always in complex cases.
3.3 Lifetime Extension Doesn't Cross Function Boundaries
const int& returnByConstRef() {
return 5; // temporary object — destroyed at end of function
}
int main() {
const int& ref{returnByConstRef()}; // dangling!
std::cout << ref; // undefined behavior
}Even though const int& normally extends the lifetime of a temporary, this extension does not work across function boundaries.
3.4 When to Return by Reference
| Situation | Return by... | Example |
|---|---|---|
| Calculated new value | Value | int sum(int a, int b) |
| Static local / member variable | const reference | const std::string& getName() |
| Parameter passed by reference | Reference (same lifetime) | ostream& operator<<(ostream& out, ...) |
| Local variable | ❌ Never by reference | — |
4. Default Constructors and Default Arguments
Corresponds to LearnCpp 14.11 — Default constructors and default arguments
4.1 What Is a Default Constructor?
A default constructor is one that can be called with no arguments. It can be:
- A constructor with no parameters
- A constructor where all parameters have default values
class Complex {
float re, im;
public:
Complex(float newRe = 0.0, float newIm = 0.0) // default constructor (all params have defaults)
: re(newRe), im(newIm) {}
};
Complex c1{}; // calls Complex(0.0, 0.0)
Complex c2{1.0, 2.0}; // calls Complex(1.0, 2.0)4.2 Implicit vs Explicit Default Constructor
If you write no constructors at all, the compiler generates an implicit default constructor for you: zero parameters, no member initializer list, empty body. Its only job is to let Foo foo{}; compile. It's generated only when the class has zero user-declared constructors.
If you write any non-default constructor (e.g., Foo(int x)), the compiler stops generating the implicit default constructor — now Foo foo{}; becomes a compile error. To get the default constructor back without writing an empty function body, use = default:
class Foo {
public:
Foo() = default; // explicitly defaulted default constructor
Foo(int x, int y); // non-default constructor
};
Foo f1{}; // ✅ uses Foo() = default
Foo f2{1, 2}; // ✅ uses Foo(int, int)4.3 = default vs Foo() {}: The Behavioral Difference
These two look almost identical — both give you a no-argument constructor — but they behave differently in a way that can bite you:
class Written {
int m_a; // no default initializer
int m_b{}; // has default initializer
public:
Written() {} // user-written empty body
};
class Defaulted {
int m_a; // no default initializer
int m_b{}; // has default initializer
public:
Defaulted() = default; // explicitly defaulted
};
Written w{}; // what's in w.m_a ?
Defaulted d{}; // what's in d.m_a ?| Member | Written w{} |
Defaulted d{} |
|---|---|---|
m_a (no initializer) |
uninitialized (garbage!) | zero-initialized to 0 |
m_b{} |
0 (from its initializer) | 0 (from its initializer) |
Why the difference? Value initialization ({}) of a class whose default constructor is implicit or = default first zero-initializes all members, then runs the constructor. But when you write Foo() {} yourself, the compiler treats it as a user-defined constructor and skips the zero-initialization step — it assumes your constructor body takes care of initialization.
⚠️ Why this matters: If you write
Foo() {}thinking it's equivalent to= default, any member without an explicit default initializer ends up holding garbage. This is a silent bug that compiles cleanly and reads uninitialized memory at runtime.
✅ Best practice: Pick ONE of the two safe patterns:
- Use
Foo() = default;and let the compiler generate it- Or give every data member a default member initializer (
int m_b{};)If you do #2, the
= defaultvs{}difference becomes harmless — but #1 is still preferred as the clearer intent.
4.4 The Ambiguity Trap
A class should only have one default constructor. Having two causes ambiguity:
class Foo {
public:
Foo() {}
Foo(int x = 1, int y = 2) {} // also a default constructor!
};
Foo f{}; // ❌ compile error: ambiguous4.5 Value Initialization vs Default Initialization
Foo foo{}; // value initialization — preferred ✅
Foo foo2; // default initialization — less safe for aggregates⚠️ Best practice: Always use
{}(value initialization) for class types.
4.6 When NOT to Provide a Default Constructor
Just because you can provide a default constructor doesn't mean you should. A default constructor only makes sense when the class has a semantically meaningful default state.
Good candidate — Fraction: zero is a perfectly sensible default value.
class Fraction {
int m_num{0};
int m_den{1};
public:
Fraction() = default; // ✅ 0/1 makes sense
Fraction(int n, int d) : m_num{n}, m_den{d} {}
};
Fraction f{}; // ✅ zero fractionBad candidate — Employee: an employee with no name or ID is meaningless, so we intentionally don't provide a default constructor.
class Employee {
std::string m_name;
int m_id;
public:
// NO default constructor — omitted on purpose
Employee(std::string name, int id)
: m_name{std::move(name)}, m_id{id} {}
};
Employee e{}; // ❌ compile error — and that's exactly what we want!The compile error is your friend: it catches misuse at compile time instead of leaving you with a half-constructed object at runtime.
💡 Design principle: Default constructors are a convenience, not an obligation. Only provide one when a default-constructed object would actually be useful to someone.
5. Introduction to C-style Arrays
Corresponds to LearnCpp 17.7 — Introduction to C-style arrays
5.1 Declaration and Initialization
C-style arrays are built into the core language — no #include needed. The length must be a constant expression (known at compile time) and must be at least 1 (zero-length stack arrays are illegal):
int arr1[5]{}; // 5 zero-initialized ints
int arr2[4]{1, 2}; // partial init — tail elements zero-initialized → {1, 2, 0, 0}
constexpr int len{5};
int arr3[len]{}; // length can be a constexpr name
int bad1[3]; // ⚠️ default-initialized — elements hold garbage (NOT zero!)
int n{5};
int bad2[n]; // ❌ error — n isn't constexpr (VLAs are non-standard C++)⚠️ Default vs value init:
int arr[3];leaves elements uninitialized (garbage).int arr[3]{};zero-initializes them. Always use{}when you want zeros.
const and constexpr arrays: Arrays can be qualified like other variables. A const array must be initialized at declaration; a constexpr array is usable in constant expressions:
const int primes[]{2, 3, 5, 7, 11}; // const — elements can't be modified
constexpr int squares[5]{1, 4, 9, 16, 25}; // usable in constant expressionsPython comparison: Like a Python list with a fixed size and a single element type. No append() — size is baked in at compile time.
5.2 Length Deduction
When you explicitly initialize every element, you can omit the length and let the compiler count:
int a[]{1, 2, 3, 4, 5}; // length deduced = 5
int b[] = {10, 20, 30}; // also works (copy-list form)✅ Best practice: Prefer length deduction when you're initializing every element explicitly. That way, adding or removing elements doesn't require also updating the length — no risk of them drifting out of sync.
⚠️ Gotcha: You cannot combine empty braces with omitted length —
int arr[]{};would be a zero-length array, which is invalid. Either give it a length (int arr[5]{};) or provide initializers.
5.3 Subscripting
Arrays are 0-indexed. The index can be any integral type — signed, unsigned, or an unscoped enum. Unlike standard library containers, there are no sign-conversion warnings:
int arr[5]{9, 7, 5, 3, 1};
arr[0]; // 9
arr[4]; // 1
arr[5]; // ❌ undefined behavior — no bounds checking!
unsigned u{2};
arr[u]; // ✅ 5 — unsigned index is fine
arr[-1]; // ❌ undefined behavior (but compiles silently)⚠️ C-style arrays do no bounds checking. Any out-of-range access — positive or negative — is undefined behavior that compiles clean.
💡 Declaration vs subscript — same punctuation, different things: The
[]inint arr[5]is declaration syntax saying "this is an array of 5 ints". The[]inarr[3]is the subscript operator that reads / writes an element. Don't confuse them.
5.4 Using char as an Index
📖 Covered in Week 2 §16.5 — the
int counts[26]{0}; index = letter - 'A'; ++counts[index];idiom for per-letter bookkeeping, with the warning about not using a literal character as the index ('A'is 65, way out of bounds).
5.5 Getting the Length of an Array
sizeof(arr) returns the total size in bytes, NOT the number of elements:
int arr[5]{};
sizeof(arr); // 20 (on a system with 4-byte int)
sizeof(arr[0]); // 4A C-style array has zero overhead — its memory is exactly length × sizeof(element). So the legacy C-era formula for the element count is:
// Legacy approach (still works on non-decayed arrays)
size_t n = sizeof(arr) / sizeof(arr[0]); // 5C++17 onward introduced std::size(arr), which is safer and clearer:
#include <iterator> // or <array>, <vector>
int arr[5]{};
std::size(arr); // 5 — returns std::size_t (unsigned)
std::ssize(arr); // 5 — C++20, returns a signed type✅ Best practice: Use
std::size(arr)oversizeof(arr) / sizeof(arr[0]). It's clearer in intent, and — critically — it fails with a compile error (rather than silently returning the wrong answer) if the "array" has already decayed to a pointer. See section 6 for why this matters.
std::size vs std::ssize — why does C++20 bother adding a signed version?
Both return the same value; they differ only in return type. std::size returns std::size_t (unsigned), std::ssize returns a signed type (typically std::ptrdiff_t). The reason for having two is the unsigned underflow trap:
int empty[0]; // or a std::vector that happens to be empty
for (auto i = std::size(empty) - 1; i >= 0; --i) // 💥 infinite loop!
// size() is 0, then 0 - 1 on an unsigned type wraps to SIZE_MAX (~4 billion)The moment you subtract from a size_t and the result would go below zero, it wraps to a huge positive number instead (see section 14.2 — "Infinite Loops"). Reverse-iteration and any size - offset arithmetic are the usual victims. The signed version dodges it cleanly:
for (auto i = std::ssize(arr) - 1; i >= 0; --i) // ✅ signed — can actually be -1
std::cout << arr[i];Mixing signed and unsigned in comparisons is also a source of subtle bugs: int i < std::size(arr) triggers an implicit sign conversion of i that compilers will warn about. Using std::ssize keeps everything signed and sidesteps the warnings entirely.
✅ When to use which:
std::sizewhen you just need to hand the count to something that expectssize_t(e.g.,arr + std::size(arr)for pointer arithmetic, or STL API arguments).std::ssizewhen you'll do arithmetic or comparisons that could cross zero — reverse iteration,size - offset, comparing anint iagainst the size, etc.Guidance from respected sources (Google style guide, Bjarne Stroustrup's C++ Core Guidelines) leans toward preferring
std::ssizein new code specifically to avoid the entire family of unsigned bugs.
5.6 Arrays Don't Support Assignment
Unlike Python lists or std::vector, you cannot reassign a C-style array after declaration:
int arr[3]{1, 2, 3};
arr = {4, 5, 6}; // ❌ compile error — arrays are not assignable
int other[3]{7, 8, 9};
arr = other; // ❌ also a compile errorIf you need to replace the contents, you have to copy element by element (or use std::copy):
int src[3]{4, 5, 6};
for (int i = 0; i < 3; ++i)
arr[i] = src[i];
// or:
#include <algorithm>
std::copy(std::begin(src), std::end(src), std::begin(arr));If you genuinely need "a sequence I can reassign", use std::vector (dynamic) or std::array (fixed size, but assignable) instead.
5.7 Key Differences from std::array / std::vector
| Feature | C-style array | std::array |
std::vector |
|---|---|---|---|
| Size | Fixed, compile-time | Fixed, compile-time | Dynamic |
| Bounds checking | ❌ None | .at() only |
.at() only |
| Knows its own length | ❌ (lost on decay) | ✅ .size() |
✅ .size() |
Assignable (arr = ...) |
❌ | ✅ | ✅ |
| Can be passed to function safely | ❌ Decays to pointer | ✅ | ✅ |
auto / CTAD supported |
❌ | ✅ | ✅ |
6. C-style Array Decay
Corresponds to LearnCpp 17.8 — C-style array decay
6.1 What Is Array Decay?
When a C-style array is used in most expressions, it implicitly converts to a pointer to its first element. This is called array decay.
int arr[5]{9, 7, 5, 3, 1};
auto ptr = arr; // ptr is int*, pointing to arr[0]
// arr has type int[5], but ptr has type int*💡 Key insight: The decayed pointer loses the array's length information. This is why it's called "decay" — type information is lost.
6.2 Why Decay Exists: Passing Arrays to Functions
Decay solves two problems:
- Avoids expensive copies — only a pointer is passed, not the entire array
- Allows different-length arrays —
int[5]andint[7]both decay toint*
void printFirst(const int* arr) { // receives a pointer
std::cout << arr[0];
}
int a[5]{1, 2, 3, 4, 5};
int b[3]{10, 20, 30};
printFirst(a); // works — passes pointer to a[0]
printFirst(b); // works — passes pointer to b[0]⚠️ Critical consequence: Inside the function, you have no idea how long the array is. You must pass the length separately:
void fill(int a[], int N) { // int a[] is syntactic sugar for int* a
for (int i = 0; i < N; ++i)
a[i] = i;
}6.3 When Arrays Don't Decay
Arrays do NOT decay when used with: sizeof(), typeid(), operator&, passed by reference, or as a class member.
6.4 Range-based for and Decay
📖 Covered in Week 2 §16.4 — range-based for works on a non-decayed array (e.g., inside
main()), but fails once the array has decayed to a pointer parameter. The Week 2 section also shows the index-based alternative that still works after decay. (Connection to section 7.5: range-for is just pointer arithmetic underneath — that's why it can't handle a bare pointer.)
7. Pointer Arithmetic and Subscripting
Corresponds to LearnCpp 17.9 — Pointer arithmetic and subscripting
7.1 Pointer Arithmetic Basics
Pointers support four integer operators: +, -, ++, --. Each moves the pointer by objects — not bytes:
int arr[5]{9, 7, 5, 3, 1};
int* ptr{arr}; // points to arr[0]
ptr + 0; // address of arr[0]
ptr + 1; // address of arr[1] (4 bytes later, assuming 4-byte int)
ptr + 2; // address of arr[2]
ptr - 1; // one element BEFORE ptr (UB if outside the array!)
++ptr; // now points to arr[1] — same as ptr = ptr + 1
--ptr; // back to arr[0] — same as ptr = ptr - 1💡 Scaling rule:
ptr + nadvances byn × sizeof(*ptr)bytes. The compiler bakes the element size in — you think in elements, not bytes.
⚠️ Bounds rule: Pointer arithmetic is only defined when the resulting address points to a valid array element OR to the position one past the last element (the "one-past-end" address). Computing any address outside this range is undefined behavior — even if you never dereference it. "Reaching for element 100" in a 5-element array is UB, not just "something that might crash at runtime".
⚠️ One-past-end is addressable, but not dereferenceable: For a 5-element array,
&arr[5]is a perfectly legal address to hold and compare against — but dereferencing it is UB. This asymmetry is what makes thebegin/endpattern in 7.4 work.
7.2 Subscript = Pointer Arithmetic + Dereference
ptr[n] is exact syntactic sugar for *(ptr + n):
int arr[]{9, 7, 5, 3, 1};
const int* ptr{arr};
ptr[2]; // 5
*(ptr + 2); // 5 — literally the same thing
&arr[2]; // address of arr[2]
arr + 2; // same addressThis is why arrays are 0-based: element n lives at offset n from the start, so the compiler's address computation is simply base + n × sizeof(element) — no -1 adjustment needed.
7.3 Relative, Not Absolute
Subscripting is always relative to the pointer's current position, not to the original array's start:
int arr[]{9, 8, 7, 6, 5};
int* ptr{&arr[3]}; // points to element 3 (value 6)
ptr[0]; // 6 (arr's element 3 — "current")
ptr[1]; // 5 (arr's element 4 — "next")
ptr[-1]; // 7 (arr's element 2 — "previous")✅ Best practice: Use subscripting (
arr[i]) when you're indexing from the start of the array — the index lines up with the element number. Use pointer arithmetic (*ptr,++ptr) when you're doing relative traversal from a moving position. Mixing the two styles is a recipe for confusion.
⚠️ Negative indices like
ptr[-1]are technically valid whenptrisn't at the start, but they're a footgun. Misjudge whereptris and you fall straight into undefined behavior — silently.
7.4 Array Traversal with Pointers — The begin / end Pattern
The idiomatic way to walk a C-style array with pointers:
- Point
beginat the first element - Point
endat one past the last element - Loop while
begin != end, advancingbegineach iteration
#include <iostream>
#include <iterator>
int main() {
int arr[]{9, 7, 5, 3, 1};
const int* begin{arr}; // first element
const int* end{arr + std::size(arr)}; // ONE PAST the last element
for (; begin != end; ++begin)
std::cout << *begin << ' '; // 9 7 5 3 1
}💡 Why "one past" end? Two reasons:
- Clean loop termination:
begin != endworks naturally — when the two pointers meet, every element has been processed.- Empty ranges just work: For a zero-length range,
begin == endon the very first check, so the loop body runs zero times. No special-casing.The one-past-end address is safe to hold and compare (from 7.1's bounds rule) — you just can't dereference it.
Why this pattern matters — functions become immune to decay:
Instead of passing (array, length) separately (and hoping the caller gets the length right), you can pass a range of two pointers:
void print(const int* begin, const int* end) {
for (; begin != end; ++begin)
std::cout << *begin << ' ';
}
int arr[]{1, 2, 3, 4, 5};
print(arr, arr + std::size(arr)); // ✅ length info lives in the pointer pairThe function has everything it needs — end - begin is the element count, and the range covers exactly what the caller specified. No array-to-pointer decay surprises.
💡 Foreshadowing iterators: The
begin/endpair is the prototype for STL iterators. Every standard library container (std::vector,std::array,std::map, ...) exposes.begin()and.end()that behave just like these pointers. Every STL algorithm (std::sort,std::find,std::copy) takes a(begin, end)range. Understanding pointer traversal now makes iterators trivial to learn later.
7.5 Range-Based for Is Pointer Arithmetic Underneath
When you write a range-based for loop over a C-style array, the compiler expands it into essentially the begin / end loop from 7.4:
// What you write:
int arr[]{9, 7, 5, 3, 1};
for (int x : arr) {
std::cout << x << ' ';
}
// Roughly what the compiler generates:
int arr[]{9, 7, 5, 3, 1};
auto __begin = arr; // points to arr[0]
auto __end = arr + std::size(arr); // one-past-end
for (auto __it = __begin; __it != __end; ++__it) {
int x = *__it;
std::cout << x << ' ';
}💡 Key insight: Range-based for is not magic — it's pointer arithmetic with nicer syntax. This is also the concrete reason range-based for fails on a decayed array (a bare pointer): the compiler can't figure out where the range ends. Section 6 told you "you can't range-for a decayed array"; now you know why.
8. C-style Strings
Corresponds to LearnCpp 17.10 — C-style strings
Combined with lecture slides: C-style Strings, Traversing Strings
8.1 What Is a C-style String?
📖 Covered in Week 2 §17.1–17.2 — a C-style string is a
chararray terminated by'\0'; the five forms of initialization (char s[12] = "...",char s2[] = "...",char s3[12],const char* s4 = "...",char* s5) with their safety tradeoffs; and the physical length (total array size) vs logical length (characters before'\0') distinction.
8.2 Traversing C-style Strings
📖 Covered in Week 2 §17.3 — the five iteration methods compared:
s.size()❌ doesn't exist on C arraysstrlen()⚠️ works but O(n) per call (avoid in loop conditions — see 8.5 below)- range-based for ⚠️ iterates the physical length, not the logical length
s[i] != '\0'loop ✅ correct and efficient- pointer-style
*p != '\0'✅ idiomatic CMethods 4 and 5 are the recommended idioms.
8.3 C-string Pitfalls
- No assignment:
str = "rope";doesn't work on char arrays (usestrcpy) - No comparison with ==: Compares pointer addresses, not content (use
strcmp) - Buffer overflow: Writing past the array boundary is undefined behavior
- Forgotten null terminator: Printing without
'\0'reads random memory
8.4 Input Safety: cin >> Can Overflow Your Buffer
Classic naive code:
char name[10]{};
std::cin >> name; // ⚠️ DANGEROUSThe problem: std::cin >> name has no idea how big name actually is. When name is passed to operator>>, it decays to char* (section 6) and all length information is lost. If the user types "Alexander Hamilton" (18 characters), cin happily writes all 18 characters plus the null terminator past the end of your 10-char buffer, corrupting whatever happens to live in adjacent memory.
This is a buffer overflow — a classic security vulnerability. Writing past a stack buffer can clobber the function's return address, letting an attacker redirect execution to code of their choice. Entire classes of real-world exploits (Morris Worm, countless CVEs) are built on exactly this bug. It is not a theoretical concern.
The safe way — std::cin.getline():
#include <iostream>
#include <iterator> // for std::size
char name[10]{};
std::cin.getline(name, std::size(name)); // reads at most 9 chars + '\0'std::cin.getline(buf, max):
- reads up to
max - 1characters intobuf - stops at newline, EOF, or when the limit is hit — whichever comes first
- always appends
'\0' - any excess characters are left in (or dropped from) the input stream
⚠️ Rule: Never use
std::cin >> strwith a C-style string unless you can prove the input will fit. Usestd::cin.getline(str, std::size(str))instead. Or — far better — usestd::string, which grows automatically.
8.5 strlen() vs std::size()
| Function | Works on decayed arrays? | Returns | Includes '\0'? |
|---|---|---|---|
std::size(arr) |
❌ | Physical array length | Yes |
strlen(arr) |
✅ | Logical string length | No |
⚠️
std::strlenis O(n) — it walks the string counting characters until it hits'\0'. Don't put it in a loop condition when the string doesn't change: cache the result first.
8.6 The <cstring> Function Family
All the classic C string functions live in <cstring>. These are what HW3's String::strlen and String::strcpy are named after:
| Function | What it does | Safety |
|---|---|---|
strlen(s) |
Returns length (excluding '\0') |
✅ Safe, O(n) |
strcpy(dest, src) |
Copies src into dest |
⚠️ No size check on dest |
strncpy(dest, src, n) |
Copy up to n chars |
⚠️ May not null-terminate if strlen(src) ≥ n |
strcat(dest, src) |
Appends src to end of dest |
⚠️ No size check on dest |
strncat(dest, src, n) |
Append up to n chars |
Safer than strcat |
strcmp(a, b) |
Returns 0 if equal; <0 or >0 otherwise | ✅ Safe |
strncmp(a, b, n) |
Compare at most n chars |
✅ Safe |
strstr(hay, needle) |
Find substring; returns pointer or nullptr |
✅ Safe |
Comparing C-strings — don't use ==:
const char* a{"hello"};
const char* b{"hello"};
if (a == b) { /* ... */ } // ❌ compares POINTERS, not content
if (std::strcmp(a, b) == 0) { /* ... */ } // ✅ compares contentThis is one of the most common beginner mistakes. == on two char* compares addresses — whether the two pointers happen to point to the same memory — not whether the characters match. Only strcmp walks the characters.
⚠️
strcpy/strcatare footguns. They assume the destination buffer is large enough and will silently overflow it otherwise. Then-variants (strncpy,strncat) give you a length cap — butstrncpyhas its own quirk: ifstrlen(src) ≥ n, it does not append'\0', leaving your destination un-terminated. Read the docs before using. In HW3 you'll write your ownstrlen/strcpythat respectMAXLENexplicitly.
The "safer strcpy" spectrum — four variants, four tradeoffs:
If you look at third-party C code or documentation, you'll see four strcpy-family functions. They represent decades of the industry trying (and partly failing) to fix the original strcpy's unsafety:
| Variant | Signature | Bounds-checked? | Guarantees '\0'? |
Portable? |
|---|---|---|---|---|
strcpy |
strcpy(dest, src) |
❌ No | ✅ Yes (if src is) |
✅ Everywhere (C89) |
strncpy |
strncpy(dest, src, n) |
⚠️ Partial — caps at n |
❌ Not if strlen(src) ≥ n |
✅ Everywhere (C89) |
strlcpy |
strlcpy(dest, src, n) |
✅ Yes (writes at most n-1 chars) |
✅ Always | ⚠️ BSD / macOS native; POSIX-2024; glibc added it in 2.38 (2023); MSVC doesn't have it |
strcpy_s |
strcpy_s(dest, destsz, src) |
✅ Yes | ✅ Always (or runtime error) | ⚠️ C11 Annex K (optional); MSVC has it; glibc does NOT |
Quick comparison:
strcpy— the original. Completely ignoresdest's size. Unsafe; basically don't use in new code.strncpy— added later to provide a size cap, but with a famous flaw: if the source fills the buffer exactly, it won't null-terminate. Worse, if the source is shorter thann, it fills the remaining space with'\0'(pointlessly wasting time on large buffers). It's "safer than strcpy" but still a minefield.strlcpy— BSD's attempt at a truly safestrcpy. Writes at mostn - 1characters, always null-terminates, and returns the length it tried to write (so you can detect truncation:if (strlcpy(dest, src, n) >= n) { /* truncated */ }). Clean design, but historically not in glibc — so Linux code using it was non-portable. Now finally standardized in POSIX-2024 and added to glibc 2.38 (2023), but still not universal.strcpy_s— Microsoft's solution, later standardized as C11 Annex K. Takes the destination buffer size as an explicit parameter and returns an error code if the buffer is too small. More "defensive" thanstrlcpy, but Annex K is optional and glibc's maintainers have explicitly declined to implement it — so it's effectively MSVC-only.
💡 If you want a safe, portable C copy in 2026:
- On BSD / macOS / recent glibc: use
strlcpy- On MSVC: use
strcpy_sorstrlcpy(if available)- Best answer for portable C++: just use
std::string(next section) and stop fighting with C-strings entirely. HW3's customStringclass is the same idea — write your own bounded copy that respectsMAXLEN, and don't rely on any of the four above directly.
⚠️ For ICS 45C specifically: You'll be implementing your own
strcpyin HW3 with explicitMAXLENbounds-checking, which is effectively a ministrlcpy. The four variants above are background knowledge so you recognize them in other codebases — not something you'll call from your HW3 code.
8.7 Best Practice: Prefer std::string
✅ Best practice: Unless you have a specific reason (HW3, interop with C APIs, tight embedded systems), use
std::stringfor mutable strings andstd::string_viewfor read-only parameters. Avoid non-const C-style strings in new code.
Why:
- No manual length tracking —
std::stringknows its own size in O(1) - No buffer overflow — concatenation and assignment grow the buffer automatically
- Value semantics —
=,==,+,<, and comparison operators all just work - No null-terminator accidents — the library handles termination internally
- No decay problem —
std::stringdoesn't turn into a bare pointer when passed around
When you do use C-strings:
- HW3's custom
Stringclass — this is a learning exercise; useMAXLENas your cap - Calling C library APIs (
printf,fopen,strtol, ...) — convert via.c_str()at the boundary - Memory-constrained embedded devices where
std::string's heap allocation is too expensive
In all those cases: always track buffer size explicitly, never trust input length, and prefer n-variant functions (or your own bounds-checked helpers) over the unchecked classics.
9. Introduction to Operator Overloading
Corresponds to LearnCpp 21.1 — Introduction to operator overloading
9.1 Operators Are Functions
In C++, x + y is really a function call: operator+(x, y). The compiler has built-in versions for fundamental types. For your own classes, you can define custom versions — this is operator overloading.
Mystring s1{"Hello, "};
Mystring s2{"World!"};
std::cout << s1 + s2; // works if you overload operator+9.2 Rules and Limitations
- You can only overload existing operators (no inventing
operator**for exponentiation) - At least one operand must be a user-defined type (your class, or a standard library class like
std::string) - You cannot change the number of operands, the operator's precedence, or its associativity
- These operators cannot be overloaded at all:
?:,sizeof,::,.,.*,typeid, and the casting operators (static_cast,dynamic_cast,reinterpret_cast,const_cast)
💡 "User-defined" includes standard library types:
std::string,std::vector, etc. all count — because they're not fundamental types, they satisfy the "at least one user-defined operand" rule. Sooperator+(double, std::string)would technically be legal.
⚠️ …but don't overload with only standard library types. Leave those to the library itself. Future C++ standards might define meanings for them that conflict with yours, silently breaking your code on compiler upgrade. Always make at least one operand a type you own.
💡 Best practice: Keep overloaded operators intuitive. If the meaning isn't obvious, use a named function instead. (The next two subsections explain why this matters.)
9.3 Return Value Conventions
There's an important rule that governs what your overloaded operator should return:
| Operator category | Examples | Return by | Why |
|---|---|---|---|
| Non-mutating (computes a fresh value) | +, -, *, /, ==, <, << |
Value (String, bool, ...) |
Creates a brand-new result; nothing persistent to reference |
| Mutating the left operand | =, +=, -=, ++, -- |
Reference to the left operand (String&) |
Returns the object that was modified, enabling chaining |
Concrete example from HW3's String class:
class String {
public:
String operator+ (const String& other) const; // ✅ new result → by value
String& operator= (const String& other); // ✅ *this was modified → by reference
String& operator+=(const String& other); // ✅ *this was modified → by reference
bool operator==(const String& other) const; // ✅ fresh bool → by value
};Why operator= returns String& instead of void or String: to enable chaining.
String a, b, c;
a = b = c; // parses right-to-left as a = (b = c);
// (b = c) must return something that a can then be assigned from- Return
void→ chained assignment doesn't compile at all - Return by value → works, but every link in the chain costs an extra copy
- Return by reference → correct AND efficient
The same logic applies to operator<<: it returns std::ostream& so you can chain cout << x << y << z (see Week 2 §18.3 for the full treatment).
💡 Rule of thumb: Ask yourself — "Does this operator change
*this?" If yes → returnType&. If no → returnType(orbool, etc.) by value.
9.4 Semantic Intent: The ^ Exponentiation Trap
Why must overloaded operators feel like their natural built-in meaning? Here's a famous cautionary example from LearnCpp:
"C++ doesn't have an exponentiation operator. I'll overload
^for it —2 ^ 3will mean8."
Sounds reasonable… until someone writes this:
int result = 4 + 3 ^ 2;What the author probably meant (assuming math precedence — exponentiation binds tighter than addition):
What C++ actually computes: ^ in C++ is bitwise XOR, which has lower precedence than +. So the expression parses as:
(4 + 3) ^ 2 // = 7 ^ 2 = 5And you cannot change this — operator precedence is baked into the language grammar, not into the operator function you define. Even after your custom ^ does the right math, the surrounding expression still parses with XOR's precedence. Every reader of your code has to mentally override their precedence expectations or write bug-ridden code.
✅ Best practice (restated): Overload an operator only when its natural built-in meaning carries over to your type.
+for concatenating two strings? Fine — natural analog (merge two sequences).<<for "print this object to a stream"? Fine — already the conventional meaning.-for removing characters from a string? Terrible — no natural analog, no obvious semantics; use a named.erase()method instead.^for exponentiation? Terrible — fights the language's precedence rules; use a namedpow()function.
10. Overloading the I/O Operators
📖 Moved to Week 2 §18. The full treatment of
operator<<overloading — why it must be a non-member function, theostream& operator<<(ostream& out, const Complex& c)signature, why you returnostream&for chaining, the paralleloperator>>, and thefrienddeclaration alternative — all lives in Week 2 §18 "Overloadingoperator<<and the Reference Parameter Pattern".This section's concepts (return-by-reference conventions) connect back to §9.3 above — the same "return
Type&for chaining" rule thatoperator=follows.
11. Overloading Operators Using Member Functions
Corresponds to LearnCpp 21.5 — Overloading operators using member functions
11.1 Friend vs Member Overload
Friend/non-member version:
// Two explicit parameters
Cents operator+(const Cents& cents, int value) {
return Cents(cents.m_cents + value);
}
// Usage: cents1 + 2 → operator+(cents1, 2)Member function version:
class Cents {
public:
Cents operator+(int value) const { // only one parameter — left operand is *this
return Cents{m_cents + value};
}
};
// Usage: cents1 + 2 → cents1.operator+(2)11.2 When to Use Which
| Situation | Preferred form |
|---|---|
=, [], (), -> |
Must be member |
Unary operators (++, --, !) |
Member |
Binary, doesn't modify left operand (+, -) |
Friend/non-member |
Binary, modifies left operand (+=, -=) |
Member (preferred) |
<<, >> |
Must be non-member (left operand is ostream/istream) |
11B. Object Lifetime Walkthrough — Lecture Supplement
From lecture slides: Classes p9 — "What happens here?"
⚠️ This is an exam favorite. Trace through carefully.
11B.1 The Code
#include "Complex.hpp"
int main() {
Complex c1(1.5, 5.3); /// (1) c1 is constructed
Complex c2(2.5, 2.7); /// (2) c2 is constructed
c1.print(cout);
c2.print(cout);
{
Complex result; /// (3) result is default-constructed (0.0, 0.0)
result.print(cout);
result = c1.add(c2); /// (4) A LOT happens here! (see below)
result.print(cout);
} /// (5) result goes out of scope → destructor called
c1 = Complex(2.0, 3.0); /// (6) temporary constructed, assigned to c1, temporary destroyed
c1.print(cout);
return 0;
} /// (7) c2 destroyed, then c1 destroyed (reverse order)11B.2 Detailed Breakdown of Line (4): result = c1.add(c2)
This single line triggers many operations:
c2is copied into parametercofadd()(pass by value → copy constructor)- Inside
add():Complex(re + c.re, im + c.im)— a temporary Complex is constructed - The temporary is returned (may involve copy/move, though compiler often optimizes this away via RVO)
- The returned value is assigned to
resultviaoperator=(copy assignment) - The temporary from the return is destroyed (destructor called)
- The copy of
c(parameter) is destroyed (destructor called)
💡 Key insight: A simple-looking line like
result = c1.add(c2)can trigger 2+ constructors, 1 assignment, and 2+ destructors. This is why understanding object lifetime is critical.
11B.3 Destruction Order
Objects are destroyed in reverse order of construction:
Construction order: c1, c2, result
Destruction order: result (end of inner scope), c2, c1 (end of main)⚠️ Exam tip: When asked "what happens here?", list every constructor, destructor, and assignment call in order. Don't forget temporaries!
11B.4 Line (6): c1 = Complex(2.0, 3.0)
Complex(2.0, 3.0)— temporary object constructed (a "literal Complex")c1 = ...— copy assignment operator called onc1- Temporary is destroyed immediately after the statement
12. Basic File I/O
Corresponds to LearnCpp 28.6 — Basic file I/O
Combined with lecture slides: Process each word in a file, Copy a file
12.1 The Three File Stream Classes
All live in <fstream>:
| Class | Purpose | Default mode |
|---|---|---|
ifstream |
Read from file | ios::in |
ofstream |
Write to file | ios::out |
fstream |
Read and write | ios::in | ios::out |
12.2 Basic Usage
#include <fstream>
#include <iostream>
#include <string>
int main() {
// Writing
std::ofstream out{"output.txt"}; // opens for writing (default mode — erases existing content!)
out << "Hello\n";
// no out.close() needed — destructor closes automatically at end of scope
// Reading line by line (preferred for text files)
std::ifstream in{"input.txt"};
std::string line;
while (std::getline(in, line))
std::cout << line << '\n';
}💡 RAII auto-close: You don't strictly need to call
.close(). When theifstream/ofstreamvariable goes out of scope, its destructor closes the file automatically. This is RAII (Resource Acquisition Is Initialization) — the lifetime of the stream object manages the lifetime of the open file. Explicitclose()is only needed when you want to reopen the variable with a different file or mode (see 12.3), or when you need to force-flush before callingstd::exit()(see 12.5).
>> vs std::getline() — pick the right reader:
while (in >> word) { /* ... */ } // reads ONE WORD at a time (stops at whitespace)
while (std::getline(in, line)) { /* ... */ } // reads ONE LINE at a time (stops at '\n')operator>> breaks on any whitespace (spaces, tabs, newlines), so reading a file containing "Hello World\nGoodbye" gives you three separate tokens "Hello", "World", "Goodbye" — not lines. For most text-file work, that's not what you want.
- Use
getlinewhen you want whole lines. - Use
>>when you want tokenized input (numbers, space-separated words, etc.) and you're OK with losing the line structure.
12.3 File Modes
A file mode is a flag that controls how a file is opened. You pass it as the second argument to the constructor (or to open()):
std::ofstream out{"log.txt", std::ios::app}; // append mode — don't overwrite existing contentThe full list of mode flags (all live in the std::ios namespace):
| Mode | Meaning |
|---|---|
std::ios::app |
Append — every write goes to the end of the file; existing content preserved |
std::ios::ate |
"At end" — seek to end of file once on open; subsequent writes can go anywhere |
std::ios::binary |
Binary mode — no text translation (e.g., no \r\n ↔ \n conversion on Windows) |
std::ios::in |
Open for reading (default for ifstream) |
std::ios::out |
Open for writing (default for ofstream) |
std::ios::trunc |
Erase the file if it already exists (implied by ofstream when out is set) |
⚠️
appvsate— don't confuse them:
appforces every write to go to the end of the file (even if youseekpelsewhere first).ateseeks to the end once, when the file is opened — subsequent writes can go anywhere.
Combining modes with |: The bitwise OR operator stacks multiple flags into one argument:
std::fstream f{"data.bin", std::ios::in | std::ios::out | std::ios::binary};
// read + write + binary mode, all at onceThis is exactly how fstream's default (std::ios::in | std::ios::out) is specified under the hood. Each flag is a separate bit; | combines them.
Reopening a stream — open() / close():
A single stream variable can be closed and reopened with a different file or mode:
std::ofstream out{"log.txt"}; // opens in default write mode (truncates)
out << "First session\n";
out.close(); // close this file
out.open("log.txt", std::ios::app); // reopen in append mode
out << "Second session\n"; // doesn't overwrite the first session
// destructor closes automatically at scope endUseful when you want to change the mode mid-program without declaring a new stream variable.
12.4 Error Checking
std::ifstream in{"nonexistent.txt"};
if (!in) {
std::cerr << "Could not open file!\n";
return 1;
}The stream object converts to false if opening failed — so if (!in) or if (!in.is_open()) both work. Do this every time you open a file; silent failures are one of the most painful debugging experiences in file I/O code.
12.5 Buffering, Flushing, and When to Close
File output is buffered: out << "hello"; typically doesn't hit the disk immediately. Characters accumulate in a memory buffer, and the buffer is written out only when one of the following happens:
- The buffer fills up (automatic, size-driven)
- The stream is closed — explicitly via
close(), or implicitly when the object is destroyed - You explicitly flush via
out.flush()orout << std::flush - You write
std::endl— which writes'\n'AND flushes
⚠️ The crash /
exit()trap: If your program crashes or callsstd::exit()(orstd::abort()) before the stream's destructor runs, buffered data is lost — the file on disk won't contain your last few writes. For critical writes (logs, checkpoints), callout.flush()after each write, or explicitlyclose()the stream before exiting.
⚠️
std::endlis slow — prefer'\n'for newlines:std::endlforces a buffer flush every time. In a loop that writes thousands of lines, that's thousands of disk syncs — often 10–100× slower than necessary.
for (int i{0}; i < 1'000'000; ++i)
out << "line " << i << '\n'; // ✅ fast — one flush at close
for (int i{0}; i < 1'000'000; ++i)
out << "line " << i << std::endl; // ❌ flushes 1,000,000 timesUse '\n' for normal line endings. Reserve std::endl / out.flush() for the rare case you genuinely need to force a flush right now.
13. Code Coverage (Testing)
Corresponds to LearnCpp 9.2 — Code coverage
13.1 What Is Code Coverage?
Code coverage describes how much of your source code is executed while testing. There are several useful metrics.
13.2 Statement Coverage
Statement coverage = percentage of statements exercised by tests.
int foo(int x, int y) {
int z{y};
if (x > y) {
z = x;
}
return z;
}foo(1, 0) gives 100% statement coverage — every statement executes. But this alone isn't enough.
13.3 Branch Coverage
Branch coverage = percentage of branches executed. Every if has two branches (true and false), even without an else.
foo(1, 0) only tests the x > y true branch → 50% branch coverage. You also need foo(0, 1) to test the false branch.
For a switch statement:
bool isLowerVowel(char c) {
switch (c) {
case 'a': case 'e': case 'i': case 'o': case 'u':
return true; // branch 1
default:
return false; // branch 2
}
}Two calls needed: isLowerVowel('a') + isLowerVowel('q'). Multiple cases feeding into the same body don't need separate tests.
For chained if/else:
void compare(int x, int y) {
if (x > y) std::cout << x << " is greater than " << y;
else if (x < y) std::cout << x << " is less than " << y;
else std::cout << x << " is equal to " << y;
}3 calls needed: compare(1,0), compare(0,1), compare(0,0).
💡 Best practice: Aim for 100% branch coverage of your code.
13.4 Loop Coverage (0, 1, 2 Test)
If you have a loop, test with 0 iterations, 1 iteration, and 2 iterations. If it works for 2, it should work for all n > 2.
void spam(int timesToPrint) {
for (int count{0}; count < timesToPrint; ++count)
std::cout << "Spam! ";
}
// Test: spam(0), spam(1), spam(2)💡 Best practice: Use the 0, 1, 2 test to ensure your loops work correctly.
13.5 Testing Different Categories of Input
Don't just test normal values — think about categories:
| Type | Categories to test |
|---|---|
| Integers | Negative, zero, positive; overflow if relevant |
| Floating point | Values with precision issues: 0.1, -0.1, 0.7, -0.7 |
| Strings | Empty, alphanumeric, whitespace (leading/trailing/inner), all whitespace |
| Pointers | nullptr (covered later) |
💡 Best practice: Test different categories of input values to make sure your unit handles them properly.
13.6 Coverage Is Necessary, Not Sufficient
100% branch coverage is a floor, not a ceiling. Even perfectly-covered code can still have bugs your test suite never catches:
- Data-dependent bugs: Both branches of
if (x + y > limit)execute, but only with values that happen to work. In production, a specific combination triggers integer overflow and your tests never saw it. - Off-by-one errors: The loop branch executes, but it iterates once too many or once too few. Coverage doesn't check loop counts.
- Boundary values you forgot to try: Empty input, maximum-size input, exactly-at-the-threshold input,
0,INT_MAX,nullptr,"". Coverage tells you what ran, not which inputs you considered. - Interactions between branches: Two separate
ifstatements each tested in isolation can combine in ways that break — especially when they share mutable state. - Missing requirements: If the spec says "handle negative input" and you forgot to write that branch, covering the branches you did write is irrelevant.
The correct mental model:
coverage tells you "what code ran"
correctness tells you "whether that code produced the right answer for every meaningful input"These are different things. Coverage is a necessary condition — if a line never runs during testing, you're flying blind about it. But coverage is not sufficient — running a line once doesn't prove it works for every input that matters.
⚠️ Common beginner trap: "My tests pass and I have 100% coverage, therefore the code is correct." This is false, and it's one of the most widespread testing misconceptions. Reach 100% branch coverage as a starting point, then ask "What input would break this?" and add tests for those cases specifically. The input-category table in 13.5 is a good prompt list, but it's not exhaustive.
💡 Practical workflow:
- Write tests until you hit 100% branch coverage (satisfies 13.3's best practice)
- Add the 0-1-2 loop tests from 13.4
- Walk through the input categories from 13.5 for every parameter
- Ask yourself: "What does the specification say should happen that I haven't tested?"
- Ask yourself: "What's the weirdest / smallest / largest / most-adversarial input a real user might give?"
Steps 1–3 are mechanical. Steps 4–5 are where the real bugs get caught.
14. Common Semantic Errors in C++
Corresponds to LearnCpp 9.3 — Common semantic errors in C++
14.1 Conditional Logic Errors
Using the wrong comparison operator:
if (x >= 5) // oops — should be x > 5
std::cout << x << " is greater than 5";Or in a loop:
for (int count{1}; count > 5; ++count) { ... } // should be < 5; body never runs14.2 Infinite Loops
int count{1};
while (count <= 10) {
std::cout << count << ' ';
// forgot ++count; → infinite loop!
}Unsigned integer trap:
for (unsigned int count{5}; count >= 0; --count) { ... }
// unsigned int can never be < 0 — wraps to 4294967295 → infinite loop!💡 Teachers love this one: The unsigned-underflow infinite loop is a classic quiz question. Any time you see
unsignedcombined with>= 0, you have an infinite loop. Use a signedintfor loop counters that might go negative, or usestd::ssize(see section 5.5) when iterating backwards through a container.
14.3 Off-by-One Errors
Using < when you need <=, or vice versa. Extremely common in loop bounds:
for (int count{1}; count < 5; ++count)
std::cout << count << ' ';
// prints: 1 2 3 4 ← wanted 1 2 3 4 5The classic fix is count <= 5, but the underlying question is always "do I want the endpoint included or excluded?" Figure this out before writing the loop — squinting at the condition afterwards is error-prone.
14.4 Operator Precedence Traps
C++ has a long operator precedence table, and some of its entries surprise people. The classic example:
int x{0}, y{3};
if (!x > y)
std::cout << "A\n"; // does NOT print (surprising!)
if (!(x > y))
std::cout << "B\n"; // prints (as intended)Why? ! (unary logical NOT) has higher precedence than > (relational), so !x > y parses as (!x) > y:
!xis!0which istrue(i.e.,1)1 > 3isfalse- So the first
ifskips its body
The second form uses explicit parentheses to force the grouping you actually meant: !(x > y) is !(0 > 3) = !false = true ✓
✅ Rule of thumb: When mixing
!,&&,||with comparison operators, always parenthesize explicitly. C++'s logical-operator precedence rules are notoriously counterintuitive.Related trap:
&&binds tighter than||, soa || b && cparses asa || (b && c), not(a || b) && c.
14.5 Floating-Point Equality Traps
Never use == to compare floating-point values. Tiny rounding errors accumulate during arithmetic, so two values you'd swear are "obviously equal" often aren't:
double sum{0.0};
for (int i{0}; i < 10; ++i)
sum += 0.1;
if (sum == 1.0)
std::cout << "exactly 1\n";
else
std::cout << "NOT exactly 1 — got " << sum << '\n';
// Output: NOT exactly 1 — got 0.99999999999999990.1 cannot be represented exactly in binary floating-point — and neither can 0.2, 0.3, and many others. Every sum += 0.1 picks up a tiny rounding error, and after ten additions the accumulated error is visible.
The correct approach — compare against an epsilon tolerance:
#include <cmath>
if (std::abs(sum - 1.0) < 1e-9)
std::cout << "approximately 1\n";For extremely large or small values, relative error is more robust. But for typical ICS 45C code, absolute epsilon is enough.
⚠️ Rule: Use
==for integers, characters, and pointers. Use epsilon comparison (std::abs(a - b) < eps) forfloat/double. No exceptions — this rule applies even for "obviously exact" values like0.5or1.0.
14.6 Integer Division Truncation
When both operands of / are integers, C++ performs integer division — the fractional part is thrown away:
int x{7}, y{2};
std::cout << x / y << '\n'; // prints 3, not 3.5
double result = x / y; // ⚠️ still 3.0 — the division happened BEFORE the assignmentThe fix is to make at least one operand floating-point, which triggers floating-point division:
double r1 = static_cast<double>(x) / y; // ✅ 3.5
double r2 = x / static_cast<double>(y); // ✅ 3.5
double r3 = x * 1.0 / y; // ✅ 3.5 (older idiom)⚠️ Common trap:
double avg = total / count;wheretotalandcountare bothint. The division truncates before thedoubleconversion happens — cast at least one operand first.
14.7 Accidental Semicolons and Missing Braces
Stray semicolon — null statement after if:
if (c == 'y'); // ⚠️ the ; ends the if — empty body
blowUpWorld(); // ← runs UNCONDITIONALLY, not inside the ifThe ; creates an empty statement, which the compiler treats as the if's body. The next line is no longer controlled by the condition. Same trap applies to while (...); and for (...);.
Missing braces — what looks indented isn't actually inside:
if (c == 'y')
std::cout << "Are you sure?\n";
blowUpWorld(); // ← ALSO runs unconditionally!An if without braces only controls the single next statement. Indentation has no meaning to the compiler — only to the reader. The second line runs regardless of c.
✅ Best practice: Always use braces for
if,while,forbodies, even for a single statement. Two characters of typing prevents this entire class of bug. Google's, LLVM's, and most other major C++ style guides mandate this.
14.8 Assignment vs Equality in Conditions
C++'s single most common typo:
char c;
std::cin >> c;
if (c = 'y') // ⚠️ assignment, not comparison!
blowUpWorld();c = 'y' is an assignment expression — it assigns 'y' to c and then evaluates to the assigned value 'y'. The if condition converts 'y' (non-zero) to true, so the body runs no matter what the user typed.
The fix is one extra =: if (c == 'y').
💡 Most modern compilers warn about this — enable
-Wall(GCC / Clang) and you'll get "suggest parentheses around assignment used as truth value". Read your warnings.
💡 Yoda conditions — some codebases write
if ('y' == c). If you accidentally type only one=, then'y' = cis a compile error (can't assign to a literal), catching the bug immediately. It's a stylistic choice, but a useful defensive pattern.
14.9 Forgetting the Function Call ()
A subtle trap — writing a function name without calling it:
int getValue() { return 5; }
int main() {
std::cout << getValue << '\n'; // ⚠️ no ()! probably prints "1"
std::cout << getValue() << '\n'; // ✅ prints 5
}Without (), getValue isn't called — instead, it evaluates to the function's address (a function pointer). The stream's << converts the pointer to bool (always true, because a valid function's address isn't zero) and prints it as 1.
On some compilers you'll see the hex address printed instead. Either way, you're not getting the return value, and nothing warns you.
⚠️ Symptom: Your program prints
1everywhere you expected to see a function's output. The fix is almost always a missing().
14.10 Other Quick-Hit Errors
- Uninitialized variables —
int x;contains garbage. Always value-initialize:int x{};, or give it a meaningful value. - Narrowing conversions —
int x = 3.7;silently truncates to3;int x{3.7};is a compile error (good!). Prefer{}initialization to catch these. - Shadowing — declaring a local variable with the same name as an outer one. Compiles fine, behaves surprisingly. Enable
-Wshadowto catch it.
15. Detecting and Handling Errors
Corresponds to LearnCpp 9.4 — Detecting and handling errors
15.1 Three Places Assumptions Fail
Most bugs come from faulty assumptions. The three most common places assumptions break:
- Function return values — assuming a called function succeeded when it didn't
- Input — assuming user input, file input, or network input has the correct format and semantics
- Arguments — assuming your caller passed valid arguments to your function
Defensive programming — writing code that survives these assumption failures — means checking each category explicitly rather than trusting it.
15.2 Four Error-Handling Strategies (Overview)
When a function detects an error, it has four choices for what to do next:
| Strategy | When to use |
|---|---|
| 1. Handle within the function | Error is recoverable locally (retry, fall back to a default, cancel operation) |
| 2. Pass the error back to the caller | Caller is in a better position to decide (return bool, sentinel, or std::optional) |
| 3. Halt the program | Error is fatal / non-recoverable — continuing would produce garbage or corrupt data |
| 4. Throw an exception | Complex propagation across many call-stack levels (full mechanism in later course / LearnCpp ch. 27) |
Sections 15.3–15.6 expand each strategy.
15.3 Strategy 1: Handle Within the Function
If the function can recover locally, handle the error right where it's detected. Two common patterns:
A) Retry until successful — typical for user input:
#include <iostream>
#include <limits>
int readPositiveInt() {
while (true) {
int x{};
std::cin >> x;
if (std::cin && x > 0) return x;
std::cin.clear(); // clear the error flags
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
std::cout << "Please enter a positive integer: ";
}
}B) Ignore or cancel, with a warning — when "don't do the bad thing" is acceptable:
void printIntDivision(int x, int y) {
if (y == 0) {
std::cerr << "Error: division by zero\n";
return; // cancel the operation
}
std::cout << x / y;
}⚠️ The hidden downside: If the caller depends on a return value or side effect, "silently cancelling" leaves the caller thinking the operation succeeded. This strategy only works when the caller can't tell the difference — typically
void-returning functions whose job is a side effect.
15.4 Strategy 2: Return the Error to the Caller
When the function can't decide what to do about the error locally, push it up to the caller. Three sub-approaches:
A) Change the return type to bool (success/fail):
bool printIntDivision(int x, int y) {
if (y == 0) {
std::cerr << "Error: could not divide by zero\n";
return false;
}
std::cout << x / y;
return true;
}
if (!printIntDivision(a, b)) {
// handle the failure in the caller
}B) Sentinel value — a special return value that means "error":
constexpr double no_reciprocal{0.0}; // 0.0 can never be a legitimate reciprocal
double reciprocal(double x) {
if (x == 0.0) return no_reciprocal; // error sentinel
return 1.0 / x;
}A sentinel value is a value with special meaning in the context of a particular function. Here, 0.0 works as the error marker because 1/x can never equal 0.0 for any real x — so the caller can unambiguously check "did I get the sentinel back?"
⚠️ Sentinel values only work when you have an "unused" value in the return type. For a function like
int square(int x), everyintis a valid return value — there's no sentinel available. In those cases, use approach C.
C) std::optional<T> (C++17) — explicit "value or nothing":
#include <optional>
std::optional<int> parseInt(const std::string& s) {
// ... parsing logic ...
if (invalid) return std::nullopt; // "no value"
return parsed_value; // actual value
}
auto result = parseInt(input);
if (result)
std::cout << "got " << *result; // dereference to access
else
std::cout << "parse failed";std::optional is the modern, type-safe way to say "this function might have no meaningful result". It works even when every value of the return type is potentially valid — no need to steal a sentinel out of the value space.
15.5 Strategy 3: Halt the Program
Some errors are non-recoverable (a.k.a. fatal errors) — the program's state is so broken that continuing would produce garbage or corrupt data. In those cases, halt immediately.
#include <cstdlib>
double doIntDivision(int x, int y) {
if (y == 0) {
std::cerr << "Fatal: divide by zero in doIntDivision\n";
std::exit(1); // terminate with non-zero status
}
return static_cast<double>(x) / y;
}The halt options:
| Function | Behavior | When to use |
|---|---|---|
return 1; from main() |
Normal exit with error status | Error detected in main() itself (or a direct callee you can return to) |
std::exit(code) |
Immediate termination — runs static destructors and atexit handlers |
Deep inside the call stack, but a clean shutdown is still possible |
std::abort() |
Immediate abnormal termination — skips cleanup, may dump core | The bug implies memory / state is corrupted; running destructors might itself be dangerous |
assert(cond) |
abort() if cond is false — compiled out in release builds (NDEBUG) |
Sanity-check invariants during development; a failed assert indicates a programmer bug, not bad input |
💡 Rule of thumb: Halting is a blunt instrument. Use it only for truly non-recoverable situations — not "I don't feel like writing the error path". If the caller could reasonably handle the error, prefer Strategy 2.
15.6 Strategy 4: Throw an Exception
C++ has a full exception mechanism (throw / try / catch) that propagates errors up the call stack automatically until someone catches them. This decouples error detection from error handling across many function layers:
#include <stdexcept>
double divide(int x, int y) {
if (y == 0)
throw std::runtime_error{"divide by zero"};
return static_cast<double>(x) / y;
}
try {
auto r = divide(a, b);
std::cout << r;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << '\n';
}Exceptions are covered in detail in LearnCpp chapter 27 and later in the ICS 45C sequence. For Week 3, you just need to know they exist as the fourth strategy — you won't be asked to write try / catch yet.
15.7 Where to Print Errors — std::cout vs std::cerr
C++ gives you two output streams, and they're not interchangeable:
std::cout— standard output, for normal program output and user-facing resultsstd::cerr— standard error, for error messages and diagnostics
Why the split? On any Unix-like OS (and Windows), you can redirect them separately at the shell:
./myprog > output.log 2> error.log # cout → output.log, cerr → error.log
./myprog 2>/dev/null # discard errors, keep normal output
./myprog | grep something # pipe only cout; cerr still reaches the terminalIf you send errors to cout, they get mixed in with your normal output and can't be filtered out afterwards. Sending them to cerr keeps the streams separate — the user can capture them independently.
Guidelines:
| Kind of output | Where it goes |
|---|---|
| Normal program results | std::cout |
| "Please enter a positive integer" in an interactive program | std::cout (users see it in the normal flow) |
| Diagnostic / warning / internal error messages | std::cerr |
| Progress indicators, percent-complete, log-like output | std::cerr (so they don't pollute piped output) |
| Errors from non-interactive tools (CLI utilities) | std::cerr |
💡
std::cerris unbuffered by default — error messages appear immediately, even if the program crashes a moment later. That's another reason to use it for errors: you won't lose the last few messages to a buffer that never flushed (see section 12.5).
15.8 Happy Path and Sad Path Testing
Defensive programming = the practice of anticipating how code can be misused — by users, by other developers, or by your future self — and writing it to survive.
Every function has two conceptual execution paths:
- Happy path: the code path when everything is valid and nothing unusual happens. Easy to test; most new programmers stop here.
- Sad path: the code path when something goes wrong — invalid input, bad arguments, a failed call, unexpected state. This is where the real bugs hide.
void printDivision(int x, int y) {
// happy path
if (y != 0) {
std::cout << x / y;
return;
}
// sad path — don't forget to test this!
std::cerr << "Error: division by zero\n";
}✅ Best practice: For every function you write, explicitly ask "what's the sad path?" and add a test case for it. If the answer is "this function has no sad path", you're probably missing something — most functions can be called with invalid arguments, receive bad input, or have their dependencies fail.
This ties directly back to section 13.5 (test different categories of input) and 13.6 (coverage is necessary, not sufficient): coverage of the happy path alone does not tell you whether the sad path is correct.
16. Custom String Class — Lecture Supplement
From lecture slides: Strings (String.hpp, helper functions, TDD development process)
16.1 Class String Architecture
The course's custom String class uses a fixed-size char buffer internally:
class String {
static constexpr int MAXLEN = 256;
char buf[MAXLEN];
public:
explicit String(const char* s = ""); // constructor from C-string
String(const String& s); // copy constructor
String& operator=(const String& s); // copy assignment
// ... other methods
};16.2 Copy Constructor and Copy Assignment (The Rule of Three)
The String class declares two special member functions that we've seen referenced but never defined — the copy constructor and the copy assignment operator. Together with the destructor, they're called the Rule of Three: if your class needs to customize any one of them, it almost always needs all three.
| Special function | Signature | Called when... |
|---|---|---|
| Destructor | ~String() |
object goes out of scope or is explicitly destroyed |
| Copy constructor | String(const String& other) |
a new String is being initialized from an existing one |
| Copy assignment | String& operator=(const String& other) |
an already-constructed String is reassigned from another |
Which one runs? The distinction is whether *this already exists or is being created right now:
String a{"Hello"};
String b{a}; // ← copy constructor: b is BEING CREATED from a
String c; // default constructor: c now exists, empty
c = a; // ← copy assignment: c already exists, now overwritten from aA common beginner confusion: String b = a; also calls the copy constructor, not the assignment operator — because b is being initialized, not reassigned. The = here is initialization syntax, not assignment.
Copy constructor — builds *this from a source:
String::String(const String& other) {
strcpy(buf, other.buf); // copy source's buffer into our own
}Notice it takes const String&:
&so we don't infinitely recurse (passing by value would itself need a copy constructor to construct the parameter... which needs another copy constructor... stack overflow)constso we promise not to modify the source
Copy assignment — replaces *this's contents from a source, safely:
String& String::operator=(const String& other) {
if (this == &other) return *this; // guard against self-assignment (a = a)
strcpy(buf, other.buf); // replace our contents with other's
return *this; // return *this by reference for chaining
}Three key points:
Self-assignment guard (
this == &other): Protects againsta = a. For a simple class with a fixedcharbuffer this is harmless, but for classes that free old memory before assigning new memory, self-assignment without the guard would free the source data before copying it — a disaster. Build the habit now.Return
*thisby reference: Lets you chain assignments likea = b = c;. If you returned by value orvoid, chaining breaks.Existing contents: Unlike the copy constructor,
*thisalready holds data when assignment runs. In a class that manages pointers or file handles, you'd need to release the old resource before taking on the new one. ForStringwith a fixed buffer,strcpyjust overwrites — no cleanup needed.
💡 Why "Rule of Three"? If your class needs custom behavior for any of {destructor, copy constructor, copy assignment}, it almost always needs custom behavior for all three — because all three touch the resource-management logic. The compiler generates trivial defaults for each; the moment one of them becomes non-trivial, the others usually do too.
⚠️ Reality check for
Stringwith fixedchar buf[MAXLEN]: The compiler-generated defaults would actually work for this class —bufis part of the object, so default member-wise copy produces the right result. HW3 asks you to implement them explicitly anyway — partly for practice, partly to make copy semantics visible in code, and partly so that you understand what's happening when you later work with classes that do manage dynamic memory (Week 4 and beyond).
16.3 Static Helper Functions vs Public Methods
| Static helpers | Public methods |
|---|---|
General-purpose (e.g., strlen, strcpy) |
Have a this object |
Know nothing about MAXLEN |
Do error checking (size limits) |
| No error checking | Call helpers to do their work |
| Contain lower-level loops | Higher-level interface |
| Reusable across HW3 and HW4 | Specific to String class |
16.4 strlen() and strcpy() — Core Helper Implementations
These are the foundational helpers that almost every public method depends on. From lecture slides (Strings p6):
// Returns the logical length of a C-string (excludes '\0')
int strlen(const char src[]) {
int len = 0;
while (src[len] != '\0')
++len;
return len;
}// Copies src into dest (including '\0')
// Caller must ensure dest is large enough
char* strcpy(char* dest, const char* src) {
int i = 0;
while (src[i] != '\0') {
dest[i] = src[i];
++i;
}
dest[i] = '\0'; // don't forget the null terminator!
return dest;
}⚠️ These helpers do NO error checking and know nothing about MAXLEN. The public methods that call them are responsible for bounds checking. This separation of concerns is a key design principle of HW3.
💡 Python comparison:
strlen≈len(s),strcpy≈dest = src(but in C you must manually copy character by character).
16.5 TDD Development Process (8 Steps)
From lecture — the process for implementing each method:
- Pick a public method (e.g.,
reverse()) - Write a nominal test case
- Identify/invent useful helper function (e.g.,
reverse_cpy()) - Write public method using only helpers
- Write helper functions
- Test helpers independently
- Test the public method
- Review: Can it be more efficient? Better names? Extract another helper?
16.6 Example: reverse() Implementation
// Helper: copies src reversed into dest
void reverse_cpy(char* dest, const char* src) {
int src_len = strlen(src);
int src_index = src_len - 1;
int dest_index = 0;
for (; dest_index < src_len; ++dest_index, --src_index)
dest[dest_index] = src[src_index];
dest[dest_index] = '\0'; // don't forget the null terminator!
}
// Public method
String String::reverse() {
String temp;
reverse_cpy(temp.buf, this->buf);
return temp;
}16.7 Testing Private Helpers — The "Make Public" Trick
From lecture (Strings p14): Helper functions like reverse_cpy are normally private (or static private). But how do you test them directly?
The trick: Temporarily change private: to public: in the class definition, write your tests, then change it back.
// In student_gtests.cpp (or standard_main.cpp):
void test_reverse_cpy() {
cout << "Testing reverse_cpy()" << endl;
char A[100] = "Hello World";
char R[100];
String::reverse_cpy(R, A); // only works when reverse_cpy is made public!
cout << "|" << A << "| reversed is |" << R << "|" << endl;
cout << "strlen(" << A << ") is " << strlen(A) << endl;
cout << "strlen(" << R << ") is " << strlen(R) << endl;
}The testing loop (from lecture p15-16):
Test helper → works? ─── No → fix helper, re-test
└── Yes → add more edge cases
└── can't think of more → move on to test public method
Test public method → works? ─── No → fix public method, re-test
└── Yes → add boundary tests
└── can't think of more → Step 8 (review & refine)💡 Lecture mantra: "How NOT to write correct code" — Don't say "I'll clean it up later." Clean it up NOW. If your function is more than 5 lines, re-factor it. Test each piece.
16.8 Example: operator+() Implementation
String String::operator+(const String s) {
String R;
strcpy(R.buf, buf);
strncat(R.buf, s.buf, MAXLEN - 1); // prevent overflow
return R;
}⚠️ Error checking: Any time a string can grow, you must check that the result doesn't exceed
MAXLEN.
16.9 indexOf() and strstr()
int String::indexOf(const String& s) {
char* p = strstr(buf, s.buf); // find substring
if (p != nullptr)
return p - buf; // pointer arithmetic gives index
else
return -1;
}💡
p - bufworks because both point into the same array. The result is the number of elements between them.
17. The static Keyword
Corresponds to LearnCpp 7.11 — Static local variables
Combined with LearnCpp 15.6 / 15.7 — Static member variables / functions
In C++, static is an overloaded keyword — it means completely different things depending on where it appears. This section walks through all its meanings, because you'll see every one of them in this course (and in the earlier sections of these notes).
⚠️ Don't confuse these:
static(storage class / storage duration) — this sectionstatic_cast<T>(x)— a type cast operator, completely unrelated- "statically typed language" — describes C++'s compile-time type checking, also unrelated
17.1 Static Local Variables
Inside a function, a normal local variable is created each time the function is called and destroyed when the function returns. A static local variable is different: it's initialized only once (the first time control reaches the declaration) and persists for the lifetime of the program.
void counter() {
static int count{0}; // initialized ONCE, not every call
++count;
std::cout << count << '\n';
}
counter(); // prints 1
counter(); // prints 2
counter(); // prints 3Why does this matter? It lets you safely return a reference to a function-local-looking variable — the trick from the getProgramName() example earlier in these notes:
const std::string& getProgramName() {
static const std::string s_programName{"Calculator"}; // constructed once, lives forever
return s_programName; // ✅ not a dangling reference
}Without static, s_programName would be destroyed when the function returns, and the returned reference would dangle. With static, the object lives until program exit.
💡 Initialization timing: A static local is initialized the first time control reaches its declaration at runtime — not at program start. Before that moment it's zero-initialized.
💡 Naming convention: Many codebases prefix static locals with
s_to visually distinguish them from regular locals.
Python comparison: There's no direct equivalent. The closest analogy is a function-level mutable default argument (def f(cache=[]):) — it persists across calls, but the mechanism is very different.
17.2 Static Member Variables
Inside a class, static means the member belongs to the class itself, not to any particular instance. All instances share the same storage:
class Counter {
public:
static int count; // declared inside class
Counter() { ++count; }
};
int Counter::count = 0; // defined outside class (required for non-inline statics)
Counter a, b, c;
std::cout << Counter::count; // 3 — shared across all instancesAccess syntax: ClassName::member is the idiomatic form (though instance.member also compiles).
C++17 inline variables let you initialize the static inside the class, no separate definition needed:
class Counter {
public:
inline static int count = 0; // defined right here
};constexpr static members are implicitly inline, so they can be initialized directly in the class without inline:
class String {
static constexpr int MAXLEN = 256; // ✅ works directly — constexpr is implicitly inline
char buf[MAXLEN];
};This is exactly the pattern used in the Custom String Class section above — MAXLEN is shared across all String instances and baked in at compile time.
17.3 Static Member Functions
Inside a class, static on a function means no this pointer — the function doesn't operate on any particular instance. It's essentially a namespaced free function.
class Math {
public:
static int square(int x) { return x * x; }
};
int r = Math::square(5); // called without an instanceKey restrictions:
- Cannot access non-static members (there's no
this) - Cannot be declared
const(there's no object forconstto apply to) - Called with
ClassName::syntax (thoughinstance.staticFn()also compiles)
When to use them:
- Utility / helper functions logically grouped with a class but not operating on instances
- Factory functions that return new instances of the class
- The TDD helper pattern used in the Custom String Class section above — helpers like
strlen,strcpythat "know nothing aboutMAXLEN" are oftenstatic private
class String {
private:
static int strlen(const char src[]); // helper, no `this`
static char* strcpy(char* dest, const char* src);
public:
int length() const { return strlen(buf); } // public method delegates to static helper
};17.4 The Fourth Meaning (For Completeness)
There's also static at file scope (outside any function or class), which gives a name internal linkage — meaning it's only visible in that translation unit (.cpp file):
// util.cpp
static int counter = 0; // visible only in util.cpp, not in other filesThis is a C holdover. Modern C++ prefers unnamed namespaces for the same purpose:
namespace {
int counter = 0; // same effect as file-scope `static`, more idiomatic
}You're unlikely to write this yourself in ICS 45C, but it explains why static has so many meanings — each meaning comes from a different era of C/C++ design.
17.5 Summary
Where static appears |
Meaning |
|---|---|
| Local variable inside a function | Initialized once; persists for program lifetime |
| Class member variable | Shared across all instances; accessed as Class::member |
| Class member function | No this; called as Class::function() |
| File-scope variable / function | Internal linkage (visible only in this .cpp) |
💡 Mnemonic: Wherever you see
static, think "one copy, persistent" — either one copy per program (local / file-scope) or one copy per class (member).
18. Quick Reference Summary
C-style Array / Pointer Cheat Sheet
| Expression | Equivalent | Result |
|---|---|---|
arr[i] |
*(arr + i) |
Element at index i |
&arr[0] |
arr (decayed) |
Address of first element |
arr + n |
— | Address of element n |
sizeof(arr) |
— | Total bytes (NO decay) |
strlen(s) |
— | Logical length of C-string (excludes '\0') |
String Types Comparison
| Feature | char[] (C-string) |
std::string |
Custom String class |
|---|---|---|---|
| Dynamic size | ❌ Fixed | ✅ | ❌ Fixed (MAXLEN) |
Assignment with = |
❌ | ✅ | ✅ (overloaded) |
Comparison with == |
❌ (compares pointers) | ✅ | ✅ (if overloaded) |
| Null terminated | ✅ Required | ✅ (internally) | ✅ (internally) |
| Knows length | strlen() — O(n) |
.size() — O(1) |
strlen(buf) — O(n) |
Operator Overloading Decision Guide
Which operator are you overloading?
├── =, [], (), -> → MUST be member function
├── <<, >> (I/O) → MUST be non-member (friend or normal)
├── Unary (+, -, !, ++, --) → Prefer member
├── Binary, doesn't modify left (+, -, *, /) → Prefer non-member
└── Binary, modifies left (+=, -=, *=) → Prefer memberFile I/O Quick Reference
#include <fstream>
// Read entire file, line by line
std::ifstream in("file.txt");
std::string line;
while (std::getline(in, line)) { /* process line */ }
// Write to file
std::ofstream out("file.txt");
out << "content" << '\n';
// Append to file
std::ofstream out("file.txt", std::ios::app);Python vs C++ Comparison (Week 3 Additions)
| Python | C++ | Notes |
|---|---|---|
str (immutable) |
std::string (mutable) |
C++ strings can be modified in place |
input() |
std::getline(std::cin >> std::ws, s) |
Read full line |
s.find(sub) |
s.find(sub) or custom indexOf() |
Returns index or npos/-1 |
s[::-1] |
Custom reverse() |
No built-in one-liner for C-strings |
open("file") |
ifstream in("file") |
File I/O classes |
with open(...) as f: |
RAII — destructor closes file | Scope-based cleanup |
list[i] |
arr[i] or *(ptr + i) |
Pointer arithmetic underlies subscripting |
| Dynamic list | Fixed C-array + length param | Must pass length separately |
** (power) |
No equivalent operator | Cannot create operator** |
The 5 most important takeaways from Week 3:
- Array decay loses length info: When passed to a function, C-arrays become pointers — always pass the length separately
- C-strings are null-terminated char arrays: The
'\0'marks the logical end; iterate withs[i] != '\0' - operator<< must be a non-member function: Because the left operand is
ostream, not your class - Never return a local variable by reference: It's destroyed when the function ends → dangling reference
- TDD for the String class: Write test → write helper → test helper → write public method → test method → refine