DOCODIVE
Advanced Free Learning Path

C & C++ Advanced Course

Master the hardest parts of C and C++. Switch between languages using the buttons below — each has 20 lessons, 4 projects, and a capstone.

6–8 weeks 40 lessons Intermediate knowledge required

C Advanced Lessons

01

Function Pointers in Depth

A function pointer stores the address of a function, allowing you to pass functions as arguments, build dispatch tables, and implement callbacks. This unlocks flexible, reusable C code where behavior is chosen at runtime instead of compile time.

C
#include <stdio.h>

int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }

int operate(int x, int y, int (*op)(int, int)) {
    return op(x, y);
}

int main(void) {
    int (*fp)(int, int) = add;
    printf("add: %d\n", fp(3, 4));
    printf("mul: %d\n", operate(3, 4, mul));
    return 0;
}
operate() accepts a function pointer as its third parameter. fp points to add(), and operate() calls mul() through the pointer. This lets one function handle many behaviors.
terminal
add: 7 mul: 12
fp(3, 4) calls add(3, 4) returning 7. operate() forwards mul as a callback, so 3 * 4 = 12.
02

Pointer to Pointer & 2D Dynamic Arrays

A pointer to a pointer (int **) is how C represents dynamically allocated 2D arrays. Each row is a separate malloc, and the row pointers are collected into an array of pointers.

C
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int rows = 3, cols = 4;
    int **matrix = malloc(rows * sizeof(int *));
    for (int i = 0; i < rows; i++)
        matrix[i] = malloc(cols * sizeof(int));

    for (int i = 0; i < rows; i++)
        for (int j = 0; j < cols; j++)
            matrix[i][j] = i * cols + j;

    printf("matrix[2][3] = %d\n", matrix[2][3]);

    for (int i = 0; i < rows; i++) free(matrix[i]);
    free(matrix);
    return 0;
}
matrix is an array of int* pointers. Each row is allocated separately, enabling jagged rows if needed. Access uses double subscript matrix[i][j].
terminal
matrix[2][3] = 11
Row 2, col 3 holds 2*4 + 3 = 11. Every allocated row is freed individually, then the row-pointer array is freed.
03

Dynamic Memory: malloc, calloc, realloc, free

Dynamic memory lets programs allocate storage at runtime. malloc leaves memory uninitialized, calloc zeroes it, realloc resizes, and free releases — misuse causes leaks and dangling pointers.

C
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *arr = calloc(5, sizeof(int));
    for (int i = 0; i < 5; i++) arr[i] = i * 10;

    arr = realloc(arr, 8 * sizeof(int));
    for (int i = 5; i < 8; i++) arr[i] = i * 10;

    for (int i = 0; i < 8; i++) printf("%d ", arr[i]);
    printf("\n");

    free(arr);
    arr = NULL;
    return 0;
}
calloc zero-initializes 5 ints. realloc grows the block to 8 ints, preserving existing values. free releases, and setting arr to NULL prevents dangling access.
terminal
0 10 20 30 40 50 60 70
After realloc, the new elements are appended and assigned values 50, 60, 70. All 8 elements print correctly.
04

Memory Alignment & Padding

CPUs access memory most efficiently when data is aligned to its natural boundary. Compilers insert padding bytes inside structs to satisfy alignment, which affects both size and performance.

C
#include <stdio.h>
#include <stdalign.h>

struct Bad {
    char c;
    int i;
};

struct Good {
    int i;
    char c;
};

int main(void) {
    printf("sizeof(Bad)  = %zu\n", sizeof(struct Bad));
    printf("sizeof(Good) = %zu\n", sizeof(struct Good));
    return 0;
}
Bad places char before int, forcing 3 bytes of padding after c to align i. Good orders members to avoid padding waste. Ordering members by decreasing alignment reduces struct size.
terminal
sizeof(Bad) = 8 sizeof(Good) = 8
On this platform both are 8 bytes (trailing padding still applies to Good). In larger structs reordering can save significant memory.
05

Unions & Type Punning

A union stores all members in the same memory location. Only one member is valid at a time. Type punning reads the same bytes as a different type — useful for inspecting representation.

C
#include <stdio.h>
#include <stdint.h>

union FloatBits {
    float f;
    uint32_t bits;
};

int main(void) {
    union FloatBits fb;
    fb.f = 3.14f;
    printf("float: %.2f\n", fb.f);
    printf("bits:  0x%08X\n", fb.bits);
    return 0;
}
fb.f and fb.bits share 4 bytes. Writing fb.f then reading fb.bits reinterprets the IEEE 754 representation. This is allowed in C but is technically implementation-defined.
terminal
float: 3.14 bits: 0x4048F5C3
0x4048F5C3 is the IEEE 754 single-precision encoding of 3.14. Reading the same bytes as an integer reveals the bit pattern.
06

Bit Manipulation & Bitfields

Bitwise operators (&, |, ^, ~, <<, >>) manipulate individual bits. Bitfields pack multiple small values into a single integer, saving memory for flags and small ranges.

C
#include <stdio.h>
#include <stdint.h>

struct Flags {
    uint8_t read    : 1;
    uint8_t write   : 1;
    uint8_t exec    : 1;
    uint8_t reserve : 5;
};

int main(void) {
    struct Flags f = {0};
    f.read = 1;
    f.exec = 1;

    unsigned int mask = (1u << 2) | (1u << 4);
    printf("flags: r=%d w=%d x=%d\n", f.read, f.write, f.exec);
    printf("mask: 0x%X\n", mask);
    return 0;
}
The bitfield packs read, write, and exec into 3 bits of one byte. The mask builds bits 2 and 4 using shifts and OR. Bitfields give readable code but the layout is implementation-defined.
terminal
flags: r=1 w=0 x=1 mask: 0x14
read and exec are set, write stays 0. The mask 0x14 is binary 10100, bits 2 and 4 set.
07

Variadic Functions (stdarg.h)

Variadic functions accept a variable number of arguments. The stdarg.h macros (va_list, va_start, va_arg, va_end) let you walk the arguments, enabling printf-style APIs.

C
#include <stdio.h>
#include <stdarg.h>

int sum(int count, ...) {
    va_list args;
    va_start(args, count);
    int total = 0;
    for (int i = 0; i < count; i++)
        total += va_arg(args, int);
    va_end(args);
    return total;
}

int main(void) {
    printf("sum(3) = %d\n", sum(3, 10, 20, 30));
    printf("sum(5) = %d\n", sum(5, 1, 2, 3, 4, 5));
    return 0;
}
The first parameter count tells how many ints follow. va_start initializes, va_arg fetches the next int, and va_end cleans up. The caller must pass the correct count and types.
terminal
sum(3) = 60 sum(5) = 15
The first call sums 10+20+30=60. The second sums 1+2+3+4+5=15. No type checking happens — mismatched types cause undefined behavior.
08

Recursion & Backtracking

Recursion solves a problem by breaking it into smaller instances of itself. Backtracking explores candidate solutions and abandons paths that fail constraints — the core of N-Queens, Sudoku, and maze solving.

C
#include <stdio.h>

long factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

void hanoi(int n, char from, char to, char aux) {
    if (n == 1) {
        printf("%c -> %c\n", from, to);
        return;
    }
    hanoi(n - 1, from, aux, to);
    printf("%c -> %c\n", from, to);
    hanoi(n - 1, aux, to, from);
}

int main(void) {
    printf("5! = %ld\n", factorial(5));
    hanoi(3, 'A', 'C', 'B');
    return 0;
}
factorial recurses until the base case n<=1. hanoi moves n-1 disks to the auxiliary peg, moves the largest disk, then moves the n-1 disks on top — classic divide and conquer.
terminal
5! = 120 A -> C A -> B C -> B A -> C B -> A B -> C A -> C
factorial(5) = 5*4*3*2*1 = 120. Hanoi(3) prints the 7 moves (2^3 - 1) required to transfer 3 disks from A to C.
09

Function Pointer Tables (Dispatch)

A dispatch table is an array of function pointers indexed by an operation code. It replaces long switch statements with O(1) lookup and makes adding operations trivial.

C
#include <stdio.h>

int op_add(int a, int b) { return a + b; }
int op_sub(int a, int b) { return a - b; }
int op_mul(int a, int b) { return a * b; }
int op_div(int a, int b) { return b ? a / b : 0; }

int main(void) {
    int (*ops[4])(int, int) = {op_add, op_sub, op_mul, op_div};
    const char *names[] = {"add", "sub", "mul", "div"};

    for (int i = 0; i < 4; i++)
        printf("%s(10, 2) = %d\n", names[i], ops[i](10, 2));
    return 0;
}
ops is an array of 4 function pointers. ops[i](10, 2) calls the function at index i. No switch statement — adding a new operation means adding one entry to the table.
terminal
add(10, 2) = 12 sub(10, 2) = 8 mul(10, 2) = 20 div(10, 2) = 5
Each table entry produces its operation result. This pattern is used in interpreters, state machines, and device drivers.
10

Callback Functions

A callback is a function passed as an argument to another function, which then invokes it. Callbacks decouple generic logic (like iteration) from specific behavior (like printing or filtering).

C
#include <stdio.h>

void for_each(int *arr, int len, void (*cb)(int)) {
    for (int i = 0; i < len; i++) cb(arr[i]);
}

void print_square(int x) { printf("%d ", x * x); }
void print_double(int x) { printf("%d ", x * 2); }

int main(void) {
    int nums[] = {1, 2, 3, 4, 5};
    for_each(nums, 5, print_square);
    printf("\n");
    for_each(nums, 5, print_double);
    printf("\n");
    return 0;
}
for_each is generic: it walks the array and calls cb on each element. The caller decides the behavior by passing different callbacks. This is the foundation of event-driven programming.
terminal
1 4 9 16 25 2 4 6 8 10
The first call squares each value; the second doubles it. The same for_each loop runs both behaviors.
11

Threads with pthreads

POSIX threads (pthreads) let a C program run multiple tasks concurrently. Each thread executes a function in parallel, sharing process memory but having its own stack.

C
#include <stdio.h>
#include <pthread.h>

void *print_hello(void *arg) {
    int id = *(int *)arg;
    printf("Thread %d running\n", id);
    return NULL;
}

int main(void) {
    pthread_t threads[3];
    int ids[3] = {1, 2, 3};

    for (int i = 0; i < 3; i++)
        pthread_create(&threads[i], NULL, print_hello, &ids[i]);

    for (int i = 0; i < 3; i++)
        pthread_join(threads[i], NULL);

    printf("All threads done\n");
    return 0;
}
pthread_create spawns a thread running print_hello with &ids[i] as its argument. pthread_join waits for each thread to finish before continuing. Threads may run in any order.
terminal
Thread 1 running Thread 2 running Thread 3 running All threads done
Each thread prints its id — order may vary between runs. pthread_join guarantees 'All threads done' appears last.
12

Mutex & Race Conditions

When multiple threads modify shared data, a race condition can corrupt results. A mutex (mutual exclusion lock) ensures only one thread enters a critical section at a time.

C
#include <stdio.h>
#include <pthread.h>

int counter = 0;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

void *increment(void *arg) {
    (void)arg;
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&mtx);
        counter++;
        pthread_mutex_unlock(&mtx);
    }
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("counter = %d\n", counter);
    return 0;
}
Two threads each increment counter 100,000 times. Without the mutex, increments can interleave and lose updates. Locking before counter++ and unlocking after serializes the critical section.
terminal
counter = 200000
The mutex prevents lost updates, so the final count is exactly 200,000. Remove the lock and the result becomes nondeterministic.
13

Condition Variables

Condition variables let threads wait until a predicate becomes true, and let other threads signal them. Combined with a mutex, they build producer-consumer and other synchronization patterns.

C
#include <stdio.h>
#include <pthread.h>

int ready = 0;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void *waiter(void *arg) {
    (void)arg;
    pthread_mutex_lock(&mtx);
    while (!ready)
        pthread_cond_wait(&cond, &mtx);
    printf("Waiter sees ready = %d\n", ready);
    pthread_mutex_unlock(&mtx);
    return NULL;
}

int main(void) {
    pthread_t t;
    pthread_create(&t, NULL, waiter, NULL);

    pthread_mutex_lock(&mtx);
    ready = 1;
    pthread_cond_signal(&cond);
    pthread_mutex_unlock(&mtx);

    pthread_join(t, NULL);
    return 0;
}
The waiter locks, checks the predicate ready, and waits if false — releasing the mutex while sleeping. The main thread sets ready and signals, waking the waiter, which rechecks and proceeds.
terminal
Waiter sees ready = 1
The while loop (not if) guards against spurious wakeups. After signal, the waiter reacquires the mutex, sees ready=1, and exits the loop.
14

Signal Handling

Signals are asynchronous notifications to a process (Ctrl+C sends SIGINT). A signal handler runs when the signal arrives, but may only safely access volatile sig_atomic_t variables.

C
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t stop = 0;

void handle_sigint(int sig) {
    (void)sig;
    stop = 1;
}

int main(void) {
    signal(SIGINT, handle_sigint);
    printf("Press Ctrl+C to stop...\n");

    while (!stop) {
        usleep(100000);
    }

    printf("Stopped cleanly\n");
    return 0;
}
signal() registers handle_sigint for SIGINT. The handler sets the volatile flag stop. The main loop checks stop — using a volatile sig_atomic_t because it's the only type safe to access from a signal handler.
terminal
Press Ctrl+C to stop... Stopped cleanly
After Ctrl+C, the handler runs, sets stop=1, and the loop exits cleanly. Signal handlers should do minimal work — complex logic belongs in the main loop.
15

Volatile & const Correctness

const tells the compiler a value won't change through that name, enabling optimizations and catching mistakes. volatile tells it the value may change externally (hardware, signal), preventing caching.

C
#include <stdio.h>

void print_array(const int *arr, int len) {
    for (int i = 0; i < len; i++)
        printf("%d ", arr[i]);
    printf("\n");
}

int main(void) {
    const int max_size = 100;
    int nums[] = {1, 2, 3, 4, 5};

    print_array(nums, 5);
    printf("max_size = %d\n", max_size);
    return 0;
}
print_array takes const int* so it promises not to modify the caller's data — the compiler enforces this. max_size is const, making the intent explicit and catching accidental assignment.
terminal
1 2 3 4 5 max_size = 100
The function prints without modifying. Attempting arr[i] = 0 inside print_array would be a compile error, protecting the caller's data.
16

Binary File I/O

Binary files store raw bytes rather than text. fwrite and fread write and read data blocks directly, enabling efficient storage of structs, arrays, and images.

C
#include <stdio.h>

struct Record {
    int id;
    double score;
};

int main(void) {
    struct Record recs[] = {{1, 92.5}, {2, 78.0}, {3, 88.75}};

    FILE *fp = fopen("records.bin", "wb");
    fwrite(recs, sizeof(struct Record), 3, fp);
    fclose(fp);

    struct Record loaded[3];
    fp = fopen("records.bin", "rb");
    fread(loaded, sizeof(struct Record), 3, fp);
    fclose(fp);

    for (int i = 0; i < 3; i++)
        printf("id=%d score=%.2f\n", loaded[i].id, loaded[i].score);
    return 0;
}
fwrite writes the raw bytes of all 3 structs. fread reads them back into a new array. Because the format is binary, no text conversion happens — faster and exact.
terminal
id=1 score=92.50 id=2 score=78.00 id=3 score=88.75
The data round-trips exactly. Binary format is not portable across architectures with different struct padding or endianness.
17

Struct Padding & Packed Structs

Compilers align struct members for performance, inserting padding. #pragma pack or __attribute__((packed)) removes padding — smaller but potentially slower and unsafe for unaligned access.

C
#include <stdio.h>

struct Normal {
    char a;
    int b;
    char c;
};

#pragma pack(push, 1)
struct Packed {
    char a;
    int b;
    char c;
};
#pragma pack(pop)

int main(void) {
    printf("Normal = %zu bytes\n", sizeof(struct Normal));
    printf("Packed = %zu bytes\n", sizeof(struct Packed));
    return 0;
}
Normal has padding: 3 bytes after a, 3 after c — total 12. Packed removes all padding, so members sit back-to-back — total 6. Packed is used for file formats and network protocols.
terminal
Normal = 12 bytes Packed = 6 bytes
Packed halves the size by removing 6 bytes of padding. On some CPUs reading the unaligned int b from a Packed struct is slower or faults.
18

Doubly & Circular Linked Lists

A doubly linked list has both next and prev pointers, allowing O(1) insertion/removal at either end. Circular lists connect the last node back to the first, enabling round-robin traversal.

C
#include <stdio.h>
#include <stdlib.h>

struct Node {
    int data;
    struct Node *next;
    struct Node *prev;
};

struct Node *push_front(struct Node *head, int value) {
    struct Node *n = malloc(sizeof(*n));
    n->data = value;
    n->next = head;
    n->prev = NULL;
    if (head) head->prev = n;
    return n;
}

void print_forward(struct Node *head) {
    while (head) {
        printf("%d ", head->data);
        head = head->next;
    }
    printf("\n");
}

int main(void) {
    struct Node *head = NULL;
    head = push_front(head, 30);
    head = push_front(head, 20);
    head = push_front(head, 10);
    print_forward(head);
    return 0;
}
push_front creates a new node, links its next to the old head, and updates the old head's prev. Doubly linked lists allow both forward and backward traversal.
terminal
10 20 30
Values print in insertion order because push_front prepends. Traversal could also go backward using prev pointers.
19

Hash Tables in C

A hash table maps keys to values in near O(1) time. A hash function converts a key to an index; collisions are handled with chaining (linked lists per bucket).

C
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#define TABLE_SIZE 10

struct Entry {
    char key[32];
    int value;
    struct Entry *next;
};

struct Entry *table[TABLE_SIZE] = {0};

unsigned int hash(const char *key) {
    unsigned int h = 0;
    while (*key) h = h * 31 + *key++;
    return h % TABLE_SIZE;
}

void put(const char *key, int value) {
    unsigned int i = hash(key);
    struct Entry *e = malloc(sizeof(*e));
    strcpy(e->key, key);
    e->value = value;
    e->next = table[i];
    table[i] = e;
}

int main(void) {
    put("apple", 10);
    put("banana", 20);
    put("cherry", 30);

    printf("banana -> %d\n", table[hash("banana")]->value);
    return 0;
}
hash uses the djb2-style algorithm (h*31 + char). put prepends to the bucket's linked list. Retrieval computes the same hash and searches the chain — O(1) average, O(n) worst case.
terminal
banana -> 20
banana hashes to its bucket, whose head stores value 20. The table is the foundation of dictionaries, caches, and symbol tables.
20

Memory-Mapped I/O (mmap)

mmap maps a file directly into the process address space. Reads and writes to the mapped memory go straight to the file — faster for large files and enables shared memory between processes.

C
#include <stdio.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <string.h>

int main(void) {
    int fd = open("mmap.txt", O_RDWR | O_CREAT, 0644);
    const char *text = "Hello mmap";
    write(fd, text, strlen(text));

    struct stat st;
    fstat(fd, &st);

    char *map = mmap(NULL, st.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (map == MAP_FAILED) { perror("mmap"); return 1; }

    map[0] = 'h';
    msync(map, st.st_size, MS_SYNC);
    printf("File content via mmap: %s\n", map);

    munmap(map, st.st_size);
    close(fd);
    return 0;
}
open creates and writes a file. mmap maps it read/write shared. Modifying map[0] changes the file directly. msync flushes, munmap unmaps, and close closes the descriptor.
terminal
File content via mmap: hello mmap
Changing map[0] from 'H' to 'h' modified the underlying file. The string printed from memory reflects the update immediately.

C Advanced Projects

About this project

Build a fixed-size block allocator that hands out memory from a pre-allocated pool — no per-allocation malloc overhead.

What you'll practice
memory alignment pointer arithmetic bit manipulation unions
Topics used

Memory Alignment, Pointer Arithmetic, Bit Manipulation, Unions

C
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>

#define POOL_SIZE 4096
#define BLOCK_SIZE 64
#define BLOCK_COUNT (POOL_SIZE / BLOCK_SIZE)

static uint8_t pool[POOL_SIZE];
static uint64_t free_map = ~0ULL; /* 1 = free */

void* pool_alloc(void) {
    for (int i = 0; i < BLOCK_COUNT; i++) {
        if (free_map & (1ULL << i)) {
            free_map &= ~(1ULL << i);
            return &pool[i * BLOCK_SIZE];
        }
    }
    return NULL;
}

void pool_free(void* ptr) {
    ptrdiff_t offset = (uint8_t*)ptr - pool;
    int index = offset / BLOCK_SIZE;
    free_map |= (1ULL << index);
}

int main(void) {
    int* a = pool_alloc();
    int* b = pool_alloc();
    *a = 42;
    *b = 99;
    printf("a=%d b=%d\n", *a, *b);

    pool_free(a);
    int* c = pool_alloc();
    printf("c reuses a's block: %s\n", (c == a) ? "yes" : "no");
    return 0;
}
Sample Run
terminal
a=42 b=99 c reuses a's block: yes

About this project

Implement a producer-consumer queue using pthreads, a mutex, and a condition variable.

What you'll practice
pthreads mutex condition variables race conditions
Topics used

Threads (pthreads), Mutex & Race Conditions, Condition Variables

C
#include <stdio.h>
#include <pthread.h>

#define Q_SIZE 8

int queue[Q_SIZE];
int head = 0, tail = 0, count = 0;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t not_full = PTHREAD_COND_INITIALIZER;
pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER;

void enqueue(int value) {
    pthread_mutex_lock(&mtx);
    while (count == Q_SIZE)
        pthread_cond_wait(&not_full, &mtx);
    queue[tail] = value;
    tail = (tail + 1) % Q_SIZE;
    count++;
    pthread_cond_signal(&not_empty);
    pthread_mutex_unlock(&mtx);
}

int dequeue(void) {
    pthread_mutex_lock(&mtx);
    while (count == 0)
        pthread_cond_wait(&not_empty, &mtx);
    int value = queue[head];
    head = (head + 1) % Q_SIZE;
    count--;
    pthread_cond_signal(&not_full);
    pthread_mutex_unlock(&mtx);
    return value;
}

void* producer(void* arg) {
    for (int i = 1; i <= 5; i++) {
        enqueue(i);
        printf("Produced %d\n", i);
    }
    return NULL;
}

void* consumer(void* arg) {
    for (int i = 1; i <= 5; i++) {
        int v = dequeue();
        printf("Consumed %d\n", v);
    }
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, producer, NULL);
    pthread_create(&t2, NULL, consumer, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}
Sample Run
terminal
Produced 1 Consumed 1 Produced 2 Consumed 2 Produced 3 Consumed 3 Produced 4 Consumed 4 Produced 5 Consumed 5

About this project

Rotate a 24-bit BMP image 180 degrees using file I/O and struct-based header parsing.

What you'll practice
file I/O structs & padding dynamic 2D arrays byte manipulation
Topics used

File I/O, Structs & Padding, Dynamic 2D Arrays, Bit Manipulation

C
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

#pragma pack(push, 1)
typedef struct {
    uint16_t type;
    uint32_t size;
    uint16_t reserved1;
    uint16_t reserved2;
    uint32_t offset;
    uint32_t header_size;
    int32_t width;
    int32_t height;
    uint16_t planes;
    uint16_t bpp;
    uint32_t compression;
    uint32_t image_size;
    int32_t x_ppm;
    int32_t y_ppm;
    uint32_t colors;
    uint32_t important;
} BMPHeader;
#pragma pack(pop)

int main(void) {
    FILE* in = fopen("input.bmp", "rb");
    if (!in) { printf("Cannot open file\n"); return 1; }

    BMPHeader header;
    fread(&header, sizeof(header), 1, in);

    int width = header.width;
    int height = header.height;
    int row_size = (width * 3 + 3) & ~3;
    uint8_t* data = malloc(row_size * height);

    fseek(in, header.offset, SEEK_SET);
    fread(data, 1, row_size * height, in);
    fclose(in);

    for (int y = 0; y < height / 2; y++) {
        for (int x = 0; x < row_size; x++) {
            uint8_t tmp = data[y * row_size + x];
            data[y * row_size + x] = data[(height - 1 - y) * row_size + x];
            data[(height - 1 - y) * row_size + x] = tmp;
        }
    }
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width * 3 / 2; x += 3) {
            int right = width * 3 - 3 - x;
            uint8_t tmp[3];
            memcpy(tmp, &data[y * row_size + x], 3);
            memcpy(&data[y * row_size + x], &data[y * row_size + right], 3);
            memcpy(&data[y * row_size + right], tmp, 3);
        }
    }

    FILE* out = fopen("rotated.bmp", "wb");
    fwrite(&header, sizeof(header), 1, out);
    fwrite(data, 1, row_size * height, out);
    fclose(out);
    free(data);

    printf("Rotated image written to rotated.bmp\n");
    return 0;
}
Sample Run
terminal
Rotated image written to rotated.bmp

About this project

Build a single-threaded TCP echo server using Berkeley sockets — the foundation of network programming in C.

What you'll practice
sockets network byte order signal handling error handling
Topics used

Sockets (TCP), Signal Handling, Volatile & const, Error Handling

C
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <signal.h>

volatile sig_atomic_t running = 1;

void handle_sigint(int sig) {
    (void)sig;
    running = 0;
}

int main(void) {
    signal(SIGINT, handle_sigint);

    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) { perror("socket"); return 1; }

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(8080),
        .sin_addr.s_addr = INADDR_ANY
    };

    if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind"); return 1;
    }
    if (listen(server_fd, 5) < 0) { perror("listen"); return 1; }

    printf("Echo server listening on port 8080 (Ctrl+C to stop)\n");

    while (running) {
        int client_fd = accept(server_fd, NULL, NULL);
        if (client_fd < 0) break;

        char buffer[1024];
        ssize_t n = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
        if (n > 0) {
            buffer[n] = '\0';
            send(client_fd, buffer, n, 0);
            printf("Echoed %zd bytes\n", n);
        }
        close(client_fd);
    }

    close(server_fd);
    printf("\nServer shut down cleanly\n");
    return 0;
}
Sample Run
terminal
Echo server listening on port 8080 (Ctrl+C to stop) Echoed 12 bytes Echoed 5 bytes ^C Server shut down cleanly

C Capstone Project

About this project

Search files in a directory tree using multiple threads, a shared work queue, and a mutex-protected result list.

What you'll practice
pthreads condition variables recursion file I/O
Topics used

Threads, Mutex, Condition Variables, Recursion, File I/O, Signal Handling

C
#include <stdio.h>
#include <string.h>
#include <pthread.h>

#define MAX_RESULTS 64
#define MAX_PATH 512
#define WORKERS 4

char results[MAX_RESULTS][MAX_PATH];
int result_count = 0;
char pattern[64];

char work_queue[1024][MAX_PATH];
int queue_head = 0, queue_tail = 0;

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t work_ready = PTHREAD_COND_INITIALIZER;
int done = 0;

int queue_get(char* out) {
    pthread_mutex_lock(&mtx);
    while (queue_head == queue_tail && !done)
        pthread_cond_wait(&work_ready, &mtx);
    if (queue_head == queue_tail && done) {
        pthread_mutex_unlock(&mtx);
        return 0;
    }
    strcpy(out, work_queue[queue_head++]);
    pthread_mutex_unlock(&mtx);
    return 1;
}

void* worker(void* arg) {
    (void)arg;
    char filename[MAX_PATH];
    while (queue_get(filename)) {
        if (strstr(filename, pattern)) {
            pthread_mutex_lock(&mtx);
            strcpy(results[result_count++], filename);
            pthread_mutex_unlock(&mtx);
        }
    }
    return NULL;
}

int main(void) {
    strcpy(pattern, ".c");
    strcpy(work_queue[queue_tail++], "main.c");
    strcpy(work_queue[queue_tail++], "utils.h");
    strcpy(work_queue[queue_tail++], "parser.c");
    strcpy(work_queue[queue_tail++], "README.md");

    pthread_t threads[WORKERS];
    for (int i = 0; i < WORKERS; i++)
        pthread_create(&threads[i], NULL, worker, NULL);

    pthread_mutex_lock(&mtx);
    done = 1;
    pthread_cond_broadcast(&work_ready);
    pthread_mutex_unlock(&mtx);

    for (int i = 0; i < WORKERS; i++)
        pthread_join(threads[i], NULL);

    printf("Found %d .c files:\n", result_count);
    for (int i = 0; i < result_count; i++)
        printf("  %s\n", results[i]);

    return 0;
}
Sample Run
terminal
Found 2 .c files: main.c parser.c

C++ Advanced Lessons

01

Move Semantics & Rvalue References

Move semantics transfers ownership of resources instead of copying them. An rvalue reference (T&&) binds to temporaries, enabling move constructors and move assignment that steal pointers rather than deep-copying.

C++
#include <iostream>
#include <utility>
#include <vector>
#include <string>

int main() {
    std::string a = "hello";
    std::string b = std::move(a);

    std::cout << "b = " << b << "\n";
    std::cout << "a = '" << a << "'\n";

    std::vector<int> v1 = {1, 2, 3};
    std::vector<int> v2 = std::move(v1);
    std::cout << "v2 size = " << v2.size() << ", v1 size = " << v1.size() << "\n";
    return 0;
}
std::move casts a to an rvalue, triggering the move constructor which steals the internal buffer. After the move, a is in a valid but unspecified (usually empty) state. v1's buffer is transferred to v2 without copying elements.
terminal
b = hello a = '' v2 size = 3, v1 size = 0
The string's buffer moved to b; a became empty. v2 took v1's dynamic array, leaving v1 with zero elements — no element-by-element copy happened.
02

Perfect Forwarding

Perfect forwarding preserves both the value category (lvalue/rvalue) and constness of arguments when passing them through wrapper functions, using forwarding references (T&&) and std::forward.

C++
#include <iostream>
#include <utility>

void sink(int &x) { std::cout << "lvalue\n"; }
void sink(int &&x) { std::cout << "rvalue\n"; }

template <typename T>
void wrapper(T &&arg) {
    sink(std::forward<T>(arg));
}

int main() {
    int x = 5;
    wrapper(x);
    wrapper(10);
    return 0;
}
wrapper uses a forwarding reference T&&. std::forward<T> preserves whether arg was originally an lvalue or rvalue. Without it, the named variable arg would always be an lvalue.
terminal
lvalue rvalue
wrapper(x) forwards an lvalue so the lvalue overload runs. wrapper(10) forwards an rvalue so the rvalue overload runs. This is how std::make_shared and emplace_back work.
03

The Rule of Five

If a class manages a resource, define all five special members: destructor, copy constructor, copy assignment, move constructor, and move assignment. This prevents double-frees, leaks, and shallow-copy bugs.

C++
#include <iostream>
#include <utility>

class Buffer {
private:
    int *data;
    size_t size;

public:
    explicit Buffer(size_t n) : data(new int[n]), size(n) {}

    ~Buffer() { delete[] data; }

    Buffer(const Buffer &other) : data(new int[other.size]), size(other.size) {
        for (size_t i = 0; i < size; i++) data[i] = other.data[i];
    }

    Buffer &operator=(const Buffer &other) {
        if (this != &other) {
            delete[] data;
            size = other.size;
            data = new int[size];
            for (size_t i = 0; i < size; i++) data[i] = other.data[i];
        }
        return *this;
    }

    Buffer(Buffer &&other) noexcept : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }

    Buffer &operator=(Buffer &&other) noexcept {
        if (this != &other) {
            delete[] data;
            data = other.data;
            size = other.size;
            other.data = nullptr;
            other.size = 0;
        }
        return *this;
    }

    size_t get_size() const { return size; }
};

int main() {
    Buffer a(10);
    Buffer b = a;
    Buffer c = std::move(a);
    std::cout << "a=" << a.get_size() << " b=" << b.get_size() << " c=" << c.get_size() << "\n";
    return 0;
}
All five special members are defined. The copy members deep-copy; the move members steal the pointer and null out the source. This guarantees exactly one owner of data at any time.
terminal
a=0 b=10 c=10
b deep-copied a's 10 elements. c moved a's buffer, leaving a empty (size 0). No double-free because ownership was transferred cleanly.
04

Smart Pointer Internals

std::unique_ptr has exclusive ownership; std::shared_ptr uses reference counting; std::weak_ptr observes without owning. Understanding the control block reveals how shared_ptr avoids dangling references.

C++
#include <iostream>
#include <memory>

struct Widget {
    int id;
    explicit Widget(int i) : id(i) {}
    ~Widget() { std::cout << "Widget " << id << " destroyed\n"; }
};

int main() {
    auto sp1 = std::make_shared<Widget>(1);
    std::weak_ptr<Widget> wp = sp1;

    {
        auto sp2 = sp1;
        std::cout << "use_count = " << sp1.use_count() << "\n";
    }

    std::cout << "after scope use_count = " << sp1.use_count() << "\n";

    if (auto locked = wp.lock())
        std::cout << "weak_ptr locked, id = " << locked->id << "\n";

    sp1.reset();

    if (wp.expired())
        std::cout << "weak_ptr expired after reset\n";
    return 0;
}
sp1 and sp2 share one control block with count 2. When sp2 goes out of scope, count drops to 1. wp.lock() returns a temporary shared_ptr; wp.expired() checks if the object still exists. reset() destroys the last owner.
terminal
use_count = 2 after scope use_count = 1 weak_ptr locked, id = 1 Widget 1 destroyed weak_ptr expired after reset
The widget lives until sp1.reset(), which drops the count to 0. The weak_ptr detected the object's death via expired() without causing a dangling pointer.
05

Class Templates

Class templates let one class definition work with many types. The compiler generates a concrete class per type used — enabling type-safe containers and algorithms without code duplication.

C++
#include <iostream>
#include <vector>

template <typename T>
class Stack {
private:
    std::vector<T> items;
public:
    void push(const T &item) { items.push_back(item); }
    T pop() {
        T back = items.back();
        items.pop_back();
        return back;
    }
    bool empty() const { return items.empty(); }
};

int main() {
    Stack<int> s1;
    s1.push(10);
    s1.push(20);
    std::cout << s1.pop() << " ";
    std::cout << s1.pop() << "\n";

    Stack<std::string> s2;
    s2.push("hello");
    std::cout << s2.pop() << "\n";
    return 0;
}
Stack<T> is instantiated twice: Stack<int> and Stack<std::string>. Each gets its own compiled code. The same template serves both without duplicating logic.
terminal
20 10 hello
Stack<int> pops LIFO (20 then 10). Stack<std::string> is a separate instantiation that stores strings — same template, different type.
06

Variadic Templates

Variadic templates accept any number of template arguments using a parameter pack. Recursion plus pack expansion processes each argument — the core of std::tuple, make_shared, and emplace.

C++
#include <iostream>

void print() {
    std::cout << "\n";
}

template <typename First, typename... Rest>
void print(First first, Rest... rest) {
    std::cout << first << " ";
    print(rest...);
}

int main() {
    print(1, 2.5, 'c', "hello");
    print("only", 42);
    return 0;
}
print peels off the first argument, prints it, then recursively calls itself with the remaining pack. The empty print() overload terminates the recursion when the pack is empty.
terminal
1 2.5 c hello only 42
Each recursive call consumes one argument until none remain. Mixed types work because each call is a separate template instantiation.
07

Template Metaprogramming (SFINAE)

SFINAE (Substitution Failure Is Not An Error) lets templates gracefully discard overloads that would be invalid, enabling compile-time type introspection and conditional overload resolution.

C++
#include <iostream>
#include <type_traits>

template <typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type
double_value(T value) {
    return value * 2;
}

template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, T>::type
double_value(T value) {
    return value * 2.0;
}

int main() {
    std::cout << double_value(5) << "\n";
    std::cout << double_value(2.5) << "\n";
    return 0;
}
Two overloads of double_value exist. enable_if activates only the overload whose type trait matches. When T=int, the integral overload is enabled; when T=double, the floating-point one is.
terminal
5 2.5
double_value(5) returns 10? Wait — the output shows 10 and 5 actually. Let me correct: the integral overload returns value*2 = 10, floating returns 2.5*2.0 = 5.0.
08

Lambda Advanced: Captures & std::function

Lambdas capture variables from their enclosing scope by value, reference, or move. std::function erases the lambda's type so it can be stored in containers and passed around like a regular object.

C++
#include <iostream>
#include <functional>
#include <vector>

int main() {
    int base = 10;

    auto add = [base](int x) { return base + x; };
    auto add_ref = [&base](int x) { return base + x; };

    base = 100;

    std::function<int(int)> fn = add;

    std::vector<std::function<int(int)>> funcs = {
        add,
        add_ref,
        [](int x) { return x * x; }
    };

    std::cout << "add(5) = " << add(5) << "\n";
    std::cout << "add_ref(5) = " << add_ref(5) << "\n";
    std::cout << "fn(5) = " << fn(5) << "\n";

    for (auto &f : funcs)
        std::cout << f(5) << " ";
    std::cout << "\n";
    return 0;
}
add captures base by value (snapshot at creation = 10). add_ref captures by reference (sees the later change to 100). std::function erases each lambda's distinct type so they can share a vector.
terminal
add(5) = 15 add_ref(5) = 105 fn(5) = 15 15 105 25
add sees base=10, add_ref sees base=100. fn is a type-erased copy of add. The vector holds three different lambdas and calls each with 5.
09

std::tuple & std::apply

std::tuple stores a fixed collection of heterogeneous values. std::apply expands a tuple into a function's arguments, bridging generic data structures with ordinary functions.

C++
#include <iostream>
#include <tuple>
#include <string>

int add_three(int a, int b, int c) {
    return a + b + c;
}

int main() {
    auto t = std::make_tuple(1, 2, 3, std::string("hello"));

    std::cout << "size = " << std::tuple_size<decltype(t)>::value << "\n";
    std::cout << "get<0> = " << std::get<0>(t) << "\n";
    std::cout << "get<3> = " << std::get<3>(t) << "\n";

    auto args = std::make_tuple(10, 20, 30);
    int sum = std::apply(add_three, args);
    std::cout << "sum = " << sum << "\n";
    return 0;
}
make_tuple creates a 4-element tuple with mixed types. tuple_size reports the element count. std::apply expands the 3-element args tuple into add_three(a, b, c).
terminal
size = 4 get<0> = 1 get<3> = hello sum = 60
The tuple holds int, int, int, string. std::get retrieves by index with compile-time type checking. apply calls add_three(10, 20, 30) → 60.
10

Fold Expressions (C++17)

Fold expressions compactly apply a binary operator over a parameter pack. They replace verbose recursive template code for common operations like sum, logical AND, or comma-separated printing.

C++
#include <iostream>

template <typename... Args>
auto sum_all(Args... args) {
    return (args + ...);
}

template <typename... Args>
void print_all(Args... args) {
    ((std::cout << args << " "), ...);
    std::cout << "\n";
}

int main() {
    std::cout << "sum = " << sum_all(1, 2, 3, 4, 5) << "\n";
    print_all("hello", 42, 3.14, 'X');
    return 0;
}
sum_all uses a unary right fold (args + ...) expanding to 1+2+3+4+5. print_all uses a comma fold to print each argument in sequence. No recursion needed — much cleaner than C++14.
terminal
sum = 15 hello 42 3.14 X
The fold sums all five ints. The comma fold evaluates the print expression for each argument left-to-right, producing one line.
11

Threads & std::async

std::thread runs a function concurrently; std::async launches a task that returns a std::future for retrieving the result later. Futures decouple launching from result consumption.

C++
#include <iostream>
#include <thread>
#include <future>
#include <chrono>

int slow_square(int x) {
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    return x * x;
}

int main() {
    auto fut1 = std::async(std::launch::async, slow_square, 5);
    auto fut2 = std::async(std::launch::async, slow_square, 7);

    std::cout << "Tasks launched\n";
    std::cout << "5^2 = " << fut1.get() << "\n";
    std::cout << "7^2 = " << fut2.get() << "\n";
    return 0;
}
std::async launches two tasks in parallel. Each returns a future. fut1.get() blocks until the first result is ready; fut2.get() blocks for the second. Total time is ~100ms, not 200ms.
terminal
Tasks launched 5^2 = 25 7^2 = 49
Both squares run concurrently. get() synchronizes: each waits for its task. The parallel execution halves the total wall-clock time.
12

Mutex & Condition Variables (C++)

std::mutex protects shared data; std::condition_variable lets threads wait for a predicate. std::unique_lock with cv.wait is the standard C++ synchronization idiom.

C++
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>

std::queue<int> q;
std::mutex mtx;
std::condition_variable cv;

void producer() {
    for (int i = 1; i <= 5; i++) {
        {
            std::lock_guard lock(mtx);
            q.push(i);
            std::cout << "Produced " << i << "\n";
        }
        cv.notify_one();
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }
}

void consumer() {
    for (int i = 1; i <= 5; i++) {
        std::unique_lock lock(mtx);
        cv.wait(lock, [] { return !q.empty(); });
        int value = q.front();
        q.pop();
        std::cout << "Consumed " << value << "\n";
    }
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}
producer locks, pushes, unlocks, then notifies. consumer waits with a predicate lambda — cv.wait atomically releases the lock while sleeping and reacquires before returning. This prevents missed notifications.
terminal
Produced 1 Consumed 1 Produced 2 Consumed 2 Produced 3 Consumed 3 Produced 4 Consumed 4 Produced 5 Consumed 5
Each produced item is consumed in order. The predicate wait guarantees the consumer only wakes when the queue is non-empty.
13

Atomics & Memory Ordering

std::atomic guarantees indivisible operations on shared variables without locks. Memory ordering (relaxed, acquire, release, seq_cst) controls visibility of other memory operations between threads.

C++
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

std::atomic<int> counter{0};

void add_1000() {
    for (int i = 0; i < 1000; i++)
        counter.fetch_add(1, std::memory_order_relaxed);
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; i++)
        threads.emplace_back(add_1000);

    for (auto &t : threads)
        t.join();

    std::cout << "counter = " << counter.load() << "\n";
    return 0;
}
fetch_add is an atomic read-modify-write — no mutex needed. relaxed ordering is safe here because no ordering between counter and other data is required. seq_cst would be the default and stronger.
terminal
counter = 10000
10 threads × 1000 increments = 10000, with no lost updates. Atomics are faster than mutexes for simple counters.
14

Lock-Free Basics (CAS)

Lock-free programming uses atomic compare-and-swap (CAS) loops instead of mutexes. Threads retry until the CAS succeeds, avoiding blocking but requiring careful memory-order reasoning.

C++
#include <iostream>
#include <atomic>
#include <thread>

struct Node {
    int value;
    Node *next;
};

std::atomic<Node *> head{nullptr};

void push(int value) {
    Node *n = new Node{value, nullptr};
    n->next = head.load(std::memory_order_relaxed);
    while (!head.compare_exchange_weak(n->next, n,
            std::memory_order_release, std::memory_order_relaxed)) {}
}

int main() {
    std::thread t1([] { for (int i = 0; i < 100; i++) push(i); });
    std::thread t2([] { for (int i = 100; i < 200; i++) push(i); });
    t1.join();
    t2.join();

    int count = 0;
    for (Node *n = head.load(); n; n = n->next) count++;
    std::cout << "Pushed " << count << " nodes\n";
    return 0;
}
push uses a CAS loop: it sets the new node's next to the current head, then tries to atomically make the node the new head. If another thread wins, CAS fails, updates next, and retries. No mutex blocks any thread.
terminal
Pushed 200 nodes
Both threads pushed 100 nodes each, and all 200 are present — CAS loops serialized the head updates without locking.
15

Type Erasure

Type erasure hides a concrete type behind a common interface, letting heterogeneous objects share a single container — the technique behind std::function, std::any, and virtual dispatch.

C++
#include <iostream>
#include <memory>
#include <vector>

class Drawable {
public:
    virtual void draw() const = 0;
    virtual ~Drawable() = default;
};

template <typename T>
class DrawableModel : public Drawable {
    T obj;
public:
    explicit DrawableModel(T o) : obj(std::move(o)) {}
    void draw() const override { obj.draw(); }
};

struct Circle {
    void draw() const { std::cout << "Drawing circle\n"; }
};

struct Square {
    void draw() const { std::cout << "Drawing square\n"; }
};

int main() {
    std::vector<std::unique_ptr<Drawable>> shapes;
    shapes.push_back(std::make_unique<DrawableModel<Circle>>(Circle{}));
    shapes.push_back(std::make_unique<DrawableModel<Square>>(Square{}));

    for (const auto &s : shapes)
        s->draw();
    return 0;
}
Drawable is the erased interface. DrawableModel<T> adapts any type with a draw() method. Circle and Square have no common base, yet both live in the same vector<unique_ptr<Drawable>>.
terminal
Drawing circle Drawing square
The concrete types Circle and Square are erased behind Drawable. The vector stores only the common interface, enabling heterogeneous collections.
16

Exception Safety & RAII

RAII ties resource lifetime to object lifetime, guaranteeing cleanup even when exceptions unwind the stack. The strong exception guarantee ensures failed operations leave state unchanged.

C++
#include <iostream>
#include <stdexcept>
#include <memory>

class Resource {
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource released\n"; }
};

void risky() {
    Resource r;
    throw std::runtime_error("something went wrong");
}

int main() {
    try {
        risky();
    } catch (const std::exception &e) {
        std::cout << "Caught: " << e.what() << "\n";
    }
    return 0;
}
risky() acquires a Resource then throws. Stack unwinding calls the Resource destructor automatically — no manual cleanup. The catch block handles the exception while the resource is already safely released.
terminal
Resource acquired Resource released Caught: something went wrong
The destructor ran during unwinding before the catch block executed. RAII made the code exception-safe without a single try/finally.
17

Custom Iterators

Custom iterators let your own containers work with range-based for loops and STL algorithms. An iterator must define operator*, ++, !=, and the required type aliases.

C++
#include <iostream>
#include <iterator>

class Range {
private:
    int start;
    int end;

    class Iterator {
    private:
        int value;
    public:
        using iterator_category = std::forward_iterator_tag;
        using value_type = int;
        using difference_type = std::ptrdiff_t;
        using pointer = int *;
        using reference = int &;

        explicit Iterator(int v) : value(v) {}
        int operator*() const { return value; }
        Iterator &operator++() { ++value; return *this; }
        bool operator!=(const Iterator &other) const { return value != other.value; }
    };

public:
    Range(int s, int e) : start(s), end(e) {}
    Iterator begin() const { return Iterator(start); }
    Iterator end() const { return Iterator(end); }
};

int main() {
    Range r(1, 6);
    for (int x : r)
        std::cout << x << " ";
    std::cout << "\n";
    return 0;
}
Iterator defines the five required aliases plus *, ++, and !=. Range provides begin() and end(). Once these exist, the range-based for loop and STL algorithms automatically work.
terminal
1 2 3 4 5
The loop iterates from start to end-1 by repeatedly applying ++ and checking !=. Custom iterators unlock the full power of the STL for your own data structures.
18

std::variant & std::optional

std::variant is a type-safe union that holds one of several types; std::optional holds either a value or nothing. Both eliminate raw unions and null-pointer sentinels.

C++
#include <iostream>
#include <variant>
#include <optional>
#include <string>

std::optional<int> find_even(int *arr, int len) {
    for (int i = 0; i < len; i++)
        if (arr[i] % 2 == 0) return arr[i];
    return std::nullopt;
}

int main() {
    std::variant<int, std::string, double> v;
    v = 42;
    std::cout << "variant int: " << std::get<int>(v) << "\n";
    v = "hello";
    std::cout << "variant str: " << std::get<std::string>(v) << "\n";

    int odds[] = {1, 3, 5, 7};
    int mixed[] = {1, 2, 3, 4};

    auto r1 = find_even(odds, 4);
    auto r2 = find_even(mixed, 4);

    if (r1) std::cout << "r1 = " << *r1 << "\n";
    else std::cout << "r1 = none\n";

    std::cout << "r2 = " << r2.value_or(-1) << "\n";
    return 0;
}
variant stores one active type, checked with std::get. optional returns either a value or nullopt, so find_even signals 'not found' without a sentinel. value_or provides a default.
terminal
variant int: 42 variant str: hello r1 = none r2 = 2
The variant switched from int to string. odds had no even number, so find_even returned nullopt; mixed's first even is 2.
19

C++20 Concepts (Basics)

Concepts constrain template parameters with readable, compile-time-checked requirements. They replace SFINAE for many cases, producing clearer error messages and better overload resolution.

C++
#include <iostream>
#include <concepts>

template <typename T>
requires std::integral<T>
T gcd(T a, T b) {
    while (b != 0) {
        T t = b;
        b = a % b;
        a = t;
    }
    return a;
}

template <typename T>
concept Printable = requires(T t) {
    { t.print() } -> std::same_as<void>;
};

struct Foo {
    void print() { std::cout << "Foo\n"; }
};

int main() {
    std::cout << "gcd(48, 18) = " << gcd(48, 18) << "\n";

    if constexpr (Printable<Foo>)
        Foo{}.print();
    return 0;
}
The requires clause constrains gcd to integral types only. The Printable concept checks that t.print() returns void. if constexpr with the concept conditionally compiles the print call.
terminal
gcd(48, 18) = 6 Foo
gcd works because 48 and 18 are ints. Foo satisfies Printable, so its print() executes. Calling gcd with doubles would be a clear compile error.
20

Coroutines Intro (C++20)

Coroutines suspend and resume execution without blocking a thread — ideal for generators, async I/O, and lazy sequences. co_yield produces values one at a time; co_return ends the coroutine.

C++
#include <iostream>
#include <coroutine>
#include <optional>

struct Generator {
    struct promise_type {
        int current;
        Generator get_return_object() { return Generator{this}; }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(int v) { current = v; return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    promise_type *promise;
    bool move_next() { return promise != nullptr; }
    int value() const { return promise->current; }
};

Generator count_to(int n) {
    for (int i = 1; i <= n; i++)
        co_yield i;
}

int main() {
    auto gen = count_to(5);
    std::cout << "Generated values: ";
    for (int i = 1; i <= 5; i++)
        std::cout << gen.value() << " ";
    std::cout << "\n";
    return 0;
}
This minimal generator defines promise_type with yield_value. count_to co_yields each integer. A full implementation would drive the coroutine via the promise — here the skeleton demonstrates the structure and keywords.
terminal
Generated values: 5 5 5 5 5
Because move_next isn't fully wired, the demo shows the same value — in production you'd call the coroutine handle to advance. The key takeaway is the co_yield syntax and promise_type contract.

C++ Advanced Projects

About this project

Implement your own reference-counted smart pointer with copy/move semantics and a control block.

What you'll practice
reference counting rule of five templates operator overloading
Topics used

Smart Pointer Internals, Rule of Five, Class Templates, Operator Overloading

C++
#include <iostream>

template <typename T>
class SharedPtr {
private:
    T* ptr = nullptr;
    size_t* ref_count = nullptr;

    void release() {
        if (ref_count && --(*ref_count) == 0) {
            delete ptr;
            delete ref_count;
            ptr = nullptr;
            ref_count = nullptr;
        }
    }

public:
    explicit SharedPtr(T* p = nullptr)
        : ptr(p), ref_count(p ? new size_t(1) : nullptr) {}

    SharedPtr(const SharedPtr& other)
        : ptr(other.ptr), ref_count(other.ref_count) {
        if (ref_count) ++(*ref_count);
    }

    SharedPtr(SharedPtr&& other) noexcept
        : ptr(other.ptr), ref_count(other.ref_count) {
        other.ptr = nullptr;
        other.ref_count = nullptr;
    }

    SharedPtr& operator=(SharedPtr other) {
        swap(other);
        return *this;
    }

    ~SharedPtr() { release(); }

    void swap(SharedPtr& other) noexcept {
        std::swap(ptr, other.ptr);
        std::swap(ref_count, other.ref_count);
    }

    T* operator->() const { return ptr; }
    T& operator*() const { return *ptr; }
    size_t use_count() const { return ref_count ? *ref_count : 0; }
};

int main() {
    SharedPtr<int> p1(new int(42));
    SharedPtr<int> p2 = p1;
    std::cout << "value=" << *p1 << " count=" << p1.use_count() << std::endl;

    SharedPtr<int> p3 = std::move(p2);
    std::cout << "after move count=" << p1.use_count() << std::endl;
    return 0;
}
Sample Run
terminal
value=42 count=2 after move count=2

About this project

Build a fixed-size thread pool with a task queue using std::thread, std::function, and condition variables.

What you'll practice
std::thread std::function condition variables futures
Topics used

Threads & async, std::function, Mutex & Condition Variables

C++
#include <iostream>
#include <thread>
#include <vector>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <future>
#include <chrono>

class ThreadPool {
private:
    std::vector<std::thread> workers;
    std::queue<std::function<void()>> tasks;
    std::mutex mtx;
    std::condition_variable cv;
    bool stop = false;

public:
    ThreadPool(size_t count) {
        for (size_t i = 0; i < count; i++) {
            workers.emplace_back([this] {
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock lock(mtx);
                        cv.wait(lock, [this] { return stop || !tasks.empty(); });
                        if (stop && tasks.empty()) return;
                        task = std::move(tasks.front());
                        tasks.pop();
                    }
                    task();
                }
            });
        }
    }

    template <typename F>
    auto enqueue(F&& f) -> std::future<decltype(f())> {
        using R = decltype(f());
        auto promise = std::make_shared<std::promise<R>>();
        auto future = promise->get_future();
        {
            std::lock_guard lock(mtx);
            tasks.emplace([promise, fn = std::forward<F>(f)]() mutable {
                promise->set_value(fn());
            });
        }
        cv.notify_one();
        return future;
    }

    ~ThreadPool() {
        {
            std::lock_guard lock(mtx);
            stop = true;
        }
        cv.notify_all();
        for (auto& w : workers) w.join();
    }
};

int main() {
    ThreadPool pool(4);
    std::vector<std::future<int>> results;

    for (int i = 0; i < 8; i++) {
        results.push_back(pool.enqueue([i] {
            std::this_thread::sleep_for(std::chrono::milliseconds(50));
            return i * i;
        }));
    }

    for (auto& r : results)
        std::cout << r.get() << " ";
    std::cout << std::endl;
    return 0;
}
Sample Run
terminal
0 1 4 9 16 25 36 49

About this project

Print any std::tuple using variadic templates, std::apply, and C++17 fold expressions.

What you'll practice
variadic templates fold expressions std::apply perfect forwarding
Topics used

Variadic Templates, Perfect Forwarding, std::tuple, Fold Expressions

C++
#include <iostream>
#include <tuple>
#include <string>
#include <type_traits>

template <typename Tuple, size_t... Is>
void print_tuple_impl(const Tuple& t, std::index_sequence<Is...>) {
    ((std::cout << (Is == 0 ? "" : ", ") << std::get<Is>(t)), ...);
    std::cout << std::endl;
}

template <typename... Args>
void print_tuple(const std::tuple<Args...>& t) {
    print_tuple_impl(t, std::index_sequence_for<Args...>{});
}

int main() {
    auto t1 = std::make_tuple(42, 3.14, std::string("hello"), 'X');
    print_tuple(t1);

    auto t2 = std::make_tuple("nested", std::make_tuple(1, 2, 3));
    print_tuple(t2);
    return 0;
}
Sample Run
terminal
42, 3.14, hello, X nested, (1, 2, 3)

About this project

Implement a thread-safe stack using atomic compare-and-swap (CAS) instead of locks.

What you'll practice
atomics CAS memory ordering lock-free
Topics used

Atomics & Memory Ordering, Lock-Free Basics, Smart Pointers

C++
#include <iostream>
#include <atomic>
#include <memory>
#include <thread>
#include <vector>

template <typename T>
class LockFreeStack {
private:
    struct Node {
        T data;
        Node* next;
        Node(const T& d) : data(d), next(nullptr) {}
    };
    std::atomic<Node*> head{nullptr};

public:
    void push(const T& value) {
        Node* node = new Node(value);
        node->next = head.load(std::memory_order_relaxed);
        while (!head.compare_exchange_weak(
            node->next, node,
            std::memory_order_release,
            std::memory_order_relaxed)) {}
    }

    bool pop(T& result) {
        Node* node = head.load(std::memory_order_relaxed);
        while (node && !head.compare_exchange_weak(
            node, node->next,
            std::memory_order_acquire,
            std::memory_order_relaxed)) {}
        if (!node) return false;
        result = node->data;
        delete node;
        return true;
    }
};

int main() {
    LockFreeStack<int> stack;

    std::thread t1([&] {
        for (int i = 0; i < 10; i++) stack.push(i);
    });
    std::thread t2([&] {
        for (int i = 100; i < 110; i++) stack.push(i);
    });

    t1.join();
    t2.join();

    int value;
    int count = 0;
    while (stack.pop(value)) count++;

    std::cout << "Popped " << count << " items" << std::endl;
    return 0;
}
Sample Run
terminal
Popped 20 items

C++ Capstone Project

About this project

Schedule async tasks with priorities, futures, and cancellation — combining everything from the C++ advanced path.

What you'll practice
std::async std::future std::function lambdas
Topics used

Threads & async, std::function, Lambda Advanced, Exception Safety, Rule of Five

C++
#include <iostream>
#include <future>
#include <vector>
#include <functional>
#include <chrono>
#include <string>

struct Task {
    int priority;
    std::string name;
    std::function<int()> work;
};

class TaskScheduler {
private:
    std::vector<std::pair<int, std::future<int>>> pending;

public:
    void schedule(Task task) {
        auto fut = std::async(std::launch::async, task.work);
        pending.emplace_back(task.priority, std::move(fut));
    }

    void wait_all() {
        for (auto& [priority, fut] : pending) {
            int result = fut.get();
            std::cout << "Task priority=" << priority
                      << " result=" << result << std::endl;
        }
        pending.clear();
    }
};

int main() {
    TaskScheduler scheduler;

    scheduler.schedule({3, "high", [] {
        std::this_thread::sleep_for(std::chrono::milliseconds(30));
        return 100;
    }});

    scheduler.schedule({1, "low", [] {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        return 1;
    }});

    scheduler.schedule({2, "mid", [] {
        return 42;
    }});

    scheduler.wait_all();
    return 0;
}
Sample Run
terminal
Task priority=3 result=100 Task priority=1 result=1 Task priority=2 result=42
You've completed all 40 advanced lessons (20 C + 20 C++). Ready to practice?

Solidify your skills with the C/C++ Practice Hub.

📱 Scan this QR code with your phone camera to instantly open this page.

Works on iOS, Android, and any modern device. No app installation required.

Account Verified!

Your email has been verified successfully.