BrainBit Infotech Teaching · Programming Fundamentals · 20 days
C Programming
The language the operating system, the database and the Python interpreter are all written in. Learn it once, and every language after it makes more sense.
20 days
11 modules
6 tests
6 practice sets
1 capstone
works offline
Press → or Space to begin · F fullscreen · O overview
Where this course sits
Why learn C first?
- Closest mainstream language to the machine. Every variable, pointer and array maps directly onto memory you can reason about — nothing hides between your code and the hardware.
- The world runs on it. The Windows, Linux and macOS kernels, MySQL and PostgreSQL, and the CPython interpreter that runs your Python code are themselves written in C.
- Every later language borrows its syntax. C++, Java, C#, JavaScript and Go all read like dialects of C once you know it — braces, semicolons,
for loops, function calls.
- It teaches memory. Nothing else forces you to learn what a variable actually is, because nothing else lets you get it wrong.
Where C sits in your education.
- School — the first programming language most syllabi teach
- College — the first-semester programming paper
- College — the foundation the Data Structures paper builds on
💡Everything in Modules 9–11 of this course — structures, files, linked lists, stacks and queues — is exactly what a first Data Structures course assumes you already know.
11 modules · 6 tests · 6 practice sets · 1 capstone
The road ahead
| Module | Days | You will learn | Checkpoint |
| 1 | 1–2 | Foundations | Test 1 |
| 2 | 3–4 | Data & operators | Test 1 |
| 3 | 5 | Input & output | Test 1 · Days 1–5 |
| 4 | 6–7 | Control flow | Test 2 · Days 6–7 |
| 5 | 8–9 | Functions & recursion | Test 3 |
| 6 | 10–11 | Arrays & strings | Test 3 · Days 8–11 |
| 7 | 12–13 | Pointers | Test 4 |
| 8 | 14 | Dynamic memory | Test 4 · Days 12–14 |
| 9 | 15–16 | Structures, unions & enums | Test 5 |
| 10 | 17 | Files & the preprocessor | Test 5 · Days 15–17 |
| 11 | 18–20 | Data structures in C | Test 6 · Days 18–20 |
| Capstone | — | Student Record System — full CRUD console app | Final exam |
💡Every code window in this deck has a ▶ Run button that hides the output. Predict what it prints before you click — that is where the learning happens.
Day-wise plan · part 1 of 2
Days 1–10
| Day | Module | What today covers |
| 1 | Foundations |
What C is, the compilation pipeline, setup, your first program |
| 2 | Foundations |
Algorithms & flowcharts, tokens & identifiers, comments & style, the four kinds of error |
| 3 | Data & operators |
Data types & sizeof, variables, constants, literals & escape sequences, format specifiers |
| 4 | Data & operators |
Arithmetic, relational, logical & bitwise operators, precedence, type conversion |
| 5 | Input & output |
printf & scanf in depth, character I/O, the input-buffer trap, a marksheet practical |
| 6 | Control flow |
if / else-if ladders, nested if, switch & fall-through, why goto is avoided |
| 7 | Control flow |
while & do-while, for — anatomy, break & continue, nested loops, dry-running |
| 8 | Functions & recursion |
Declaring, defining & calling functions, parameters, call by value, scope, storage classes |
| 9 | Functions & recursion |
How recursion works, the call stack, recursion vs iteration |
| 10 | Arrays & strings |
Array memory model, declaring & traversing, linear & binary search, bubble, selection & insertion sort |
Day-wise plan · part 2 of 2
Days 11–20
| Day | Module | What today covers |
| 11 | Arrays & strings |
2D arrays & matrices, strings as char arrays, the null terminator, string.h, writing your own string functions |
| 12 | Pointers |
What a pointer is, & and *, pointer arithmetic, pointers & arrays, call by reference |
| 13 | Pointers |
Pointers & strings, arrays of pointers, pointer to pointer, pointer to function |
| 14 | Dynamic memory |
Stack vs heap, malloc/calloc/realloc/free, checking the return, leaks & dangling pointers |
| 15 | Structures, unions & enums |
Declaring & initialising structs, arrays & nested structs, struct pointers & -> |
| 16 | Structures, unions & enums |
typedef, union, enum, padding & sizeof |
| 17 | Files & the preprocessor |
File I/O with fopen/fread/fwrite, seeking, macros, headers & multi-file builds |
| 18 | Data structures in C |
What a data structure is, self-referential structs, building & traversing a linked list |
| 19 | Data structures in C |
Stacks & queues, array & linked implementations, balanced parentheses, circular queue |
| 20 | Data structures in C |
Doubly linked lists, Big-O without the maths, comparing array/list/stack/queue |
Before you start
How to use this deck
| Key | Action |
| → / Space | Next slide |
| ← | Previous slide |
| Home / End | First / last slide |
| O / Esc | Slide overview |
| F | Fullscreen |
▶Predict, then click. Every code window's output is hidden behind a ▶ Run button. Say out loud what it will print before you reveal it — guessing wrong is exactly where the learning happens.
Practice & test sets. Six untimed practice sets (12 long-answer questions each, 48 marks) sit before six timed tests (15 short-answer questions, 15-minute clock, model answers reveal at 00:00). Every practice set has Print worksheet and Print answer key buttons built in.
🎓Where you can stop. Teaching C as a school first language? Stop after Day 14 — pointers and dynamic memory close the loop on everything a first course needs. Modules 9–11 (structures, files, data structures) are the college syllabus.
Before Day 1
Install a compiler, then prove it works
$ gcc --version
gcc.exe (MinGW-W64 x86_64-ucrt-posix-seh) 16.1.0
$ gcc hello.c -o hello && ./hello
Hello, world!
- Windows — install WinLibs or MinGW-w64, add its
bin folder to PATH
- Ubuntu / Debian —
sudo apt install build-essential
- macOS — install the Xcode Command Line Tools:
xcode-select --install
- Prefer a full IDE instead? Code::Blocks ships a compiler and editor together
- Any plain text editor works — VS Code, Notepad++, even Notepad
💡Verify before Day 1. Run gcc --version in a terminal. If it prints a version number, you are ready. gcc hello.c -o hello compiles; ./hello runs the result.
What gcc actually does
One command, four stages
$ gcc -E hello.c -o hello.i # stop after preprocessing
$ gcc -S hello.c -o hello.s # stop after compiling
$ gcc -c hello.c -o hello.o # stop after assembling
$ gcc hello.c -o hello # run all four stages
- Preprocessor — expands
#include and #define, strips comments. hello.c → hello.i
- Compiler — translates C into assembly for your CPU.
hello.i → hello.s
- Assembler — turns assembly into machine code, an object file.
hello.s → hello.o
- Linker — joins your object file with library code (such as
printf's implementation) into one executable. hello.o → hello.exe
💡Plain gcc hello.c -o hello runs all four stages silently. The flags above let you stop and inspect any one of them — useful the first time a linker error looks nothing like the C you wrote.
01
Foundations
- What C is, where it came from, and why it still matters
- Your first program — typed, compiled and run
- Algorithms, flowcharts and the vocabulary of a program: tokens, identifiers, comments
- The four kinds of error every C programmer meets, and Practical 1 — an electricity bill
Day 01 · Module 1 · Foundations
Today we cover
- What C is, who built it, and why kernels and databases are still written in it
- The shape of every C program — the anatomy of
hello.c
- Typing, compiling and running your first program
- Reading a terminal session, command by command
By the end of today you will have typed, compiled and run a C program on your own machine, and be able to name every line of it.
History & context
A language built to write an operating system
- Dennis Ritchie designed C at Bell Labs in 1972, so that the UNIX operating system — until then written in assembly, one instruction per line of hardware — could be rewritten in something a human could read.
- Compiled vs interpreted. A compiled language translates your whole source file into machine code before it runs, producing a standalone program. An interpreted language such as Python reads and executes source line by line, every time, through another program.
- Because C compiles once to machine code, a C program starts faster and sits closer to the hardware — exactly why an operating system is written in it, not in an interpreted language.
Where C runs today, more than 50 years later.
- The Windows, Linux and macOS kernels — the core of every operating system you use
- Embedded systems — the firmware inside a washing machine, a car's ECU, a smart card
- Databases — MySQL and PostgreSQL are written in C
- The CPython interpreter — the program that runs every Python program you write is itself written in C
💡Every language you learn after this one either runs on something written in C, or borrows its syntax from it.
Reading the shape
Every C program has this shape
#include <stdio.h>
int main(void)
{
printf("Hello, world!\n");
return 0;
}
- Preprocessor directive. A line starting with
# runs before compilation — #include <stdio.h> pulls in the standard input/output declarations, including printf, before your code compiles.
int main(void) is where every C program starts. int is what it returns, void means it takes no input, and every C program has exactly one main — execution always begins there.
- The block. The matching
{ and } mark the body of main — everything the function does, grouped together.
- A statement.
printf("Hello, world!\n"); is one complete instruction. In C, a statement always ends with a semicolon.
return 0; hands control back to the operating system with the value 0, the C convention for "this program succeeded." Any other value signals failure — exactly why main is declared to return an int rather than nothing at all.
Your first program
Three lines that do something
#include <stdio.h>
int main(void)
{
printf("Hello, world!\n");
return 0;
}
#include <stdio.h> pastes the declarations of printf into your file before compiling. Without it the compiler has never heard of printf.
int main(void) is where every C program starts. The operating system calls it.
printf writes to the screen. \n is one character — a newline.
return 0; tells the OS the program succeeded. Any other number means it failed.
💡Predict before you click. Every code window in this deck hides its output behind ▶ Run. Say out loud what it will print first — that is where the learning happens.
From source file to running program
Compile once, run it any time
#include <stdio.h>
int main(void)
{
char name[20];
printf("Enter your name: ");
scanf("%19s", name);
printf("Hello, %s! Welcome to C.\n", name);
return 0;
}
$ gcc greet.c -o greet
$ ./greet
Enter your name: Asha
Hello, Asha! Welcome to C.
char name[20] declares an array — a block of 20 characters reserved for the name. scanf("%19s", name) reads into it, capped at 19 characters to leave room for the terminator. Arrays get their own module later in the course.
gcc greet.c -o greet compiles it exactly like hello.c was compiled on the last slide, writing one new file — gcc does not care whether a program reads input or not.
./greet runs it. Nothing else is left behind, unless you ask for it, the way -c asked for just the object file two slides back.
💡Re-run gcc after every edit. Editing .c source does not touch the compiled file sitting next to it — you must compile again before the change takes effect.
Day 02 · Module 1 · Foundations
Today we cover
- Algorithms and flowcharts — planning a solution before you type a single line
- Tokens and identifiers — the vocabulary the compiler reads
- Comments and style — writing code another person, including future you, can read
- The four kinds of error C throws at you, and how to tell them apart
By the end of today you can plan a short program on paper with a flowchart, name its parts correctly, and read a compiler error message instead of fearing it — then use all of it in Practical 1.
Plan before you type
Solve it on paper first
An algorithm is a finite, ordered sequence of steps that solves a problem — written in plain language, before a single line of C exists. A flowchart draws that sequence as a diagram, using five standard symbols:
| Symbol | Shape | Means |
| Terminal | Oval | Start or End |
| Input/Output | Parallelogram | Read a value in, print a value out |
| Process | Rectangle | A calculation or assignment |
| Decision | Diamond | A yes/no branch |
| Flow line | Arrow | The order steps run in |
Worked example — largest of three numbers, as pseudocode.
- Start
- Read three numbers
a, b, c
- If
a > b and a > c, then a is the largest
- Else if
b > c, then b is the largest
- Else
c is the largest
- Print the largest
- End
💡Notice this already reads like C — if, else if, else. That is not an accident: pseudocode is meant to translate almost directly. You will write this exact logic in C on Day 6.
The vocabulary of C
Six kinds of token
- Keywords — words reserved by C itself, such as
int and return. You cannot use one as a variable name.
- Identifiers — names you invent, for variables, functions and more.
- Constants — fixed values such as
42 or 3.14.
- String literals — text in double quotes, such as
"Hello".
- Operators — symbols that act on values:
+, =, ==.
- Punctuators — structural symbols:
;, { }, ( ), ,.
The 32 original C keywords — the ones you will actually use. Every one of these is off-limits as an identifier.
autobreakcasecharconstcontinuedefaultdodoubleelseenumexternfloatforgotoifintlongregisterreturnshortsignedsizeofstaticstructswitchtypedefunionunsignedvoidvolatilewhile
Later standards added 12 more, specialist ones you will not need in this course — the underscore-capital keywords such as _Bool, _Atomic and _Generic (C99 and C11), bringing C11's full count to 44.
💡Identifier rules: start with a letter or underscore, then any mix of letters, digits and underscores — no spaces, no leading digit. total_marks and _temp are valid; 2nd_year is not. Convention: lower snake_case for variables, ALL_CAPS for constants.
Code is read more than it is written
Comments say why, not what
/* Multi-line comment: everything between
these markers is ignored by the compiler. */
// Single-line comment: ignored to end of line
int total_marks = 0; // running total, not final
/* Bad: // add one to x -- repeats the code */
/* Good: // one more retry after a timeout -- explains why */
/* ... */ can span many lines and cannot be nested. // comments out only to the end of that line.
- A good comment explains why, not what. "add one to x" tells you nothing the code did not already say. "one more retry after a timeout" tells you something the code cannot.
- Indent consistently. Every statement inside
{ } gets one more level of indent — four spaces is the convention used throughout this deck.
- One statement per line.
a = 1; b = 2; compiles, but it hides a bug from your own eyes the moment one of them needs a fix.
💡You will not remember why you wrote a tricky line the way you did — not next week, and certainly not next year. The comment is a note to your future self.
Reading what went wrong
Four ways a program can fail
$ gcc broken.c -o broken
broken.c: In function 'main':
broken.c:6:5: error: expected ',' or ';' before 'printf'
6 | printf("%d\n", total);
| ^~~~~~
$ gcc nolinker.c -o nolinker
...ld.exe: undefined reference to `showMenu'
collect2.exe: error: ld returned 1 exit status
| Kind | Caught when | What you see |
| Compile-time | gcc reads your source | An error: with an exact line number, like the left |
| Linker | gcc joins the pieces into one program | An undefined reference from ld — the function exists in name only |
| Runtime | while the program is running | gcc said nothing at all. The program crashes or hangs the moment it runs — dividing by zero is a classic example |
| Logical | never | It compiles, links and runs cleanly — and prints a confidently wrong answer |
💡The one nobody warns you about is logical. No tool catches it — you catch it by working the answer out by hand and comparing. That habit is worth more than memorising error messages.
Module 1 practical
Bill an electricity meter, slab by slab
#include <stdio.h>
int main(void)
{
int units;
float charge, tax, total;
printf("Enter units consumed: ");
scanf("%d", &units);
if (units <= 100)
charge = units * 4.50f;
else if (units <= 200)
charge = 100*4.50f + (units-100)*6.00f;
else
charge = 100*4.50f + 100*6.00f
+ (units-200)*8.50f;
tax = charge * 0.05f;
total = charge + tax;
printf("\n---- Electricity Bill ----\n");
printf("%-16s%9d\n", "Units:", units);
printf("%-16s%9.2f\n", "Charge (Rs):", charge);
printf("%-16s%9.2f\n", "Tax (Rs):", tax);
printf("%-16s%9.2f\n", "Total (Rs):", total);
return 0;
}
Output
Enter units consumed: 250
---- Electricity Bill ----
Units: 250
Charge (Rs): 1475.00
Tax (Rs): 73.75
Total (Rs): 1548.75
- The slab rates. The first 100 units cost ₹4.50 each, the next 100 cost ₹6.00 each, and every unit beyond 200 costs ₹8.50. A flat 5% tax applies to the charge, not to the units.
if / else if picks which slab formula applies — this is a preview of Day 6, where conditionals are taught properly. For now, read it as plain English: "if units is at most 100, do this; otherwise if at most 200, do this; otherwise do this."
%-16s left-aligns a label in a 16-character field; %9.2f right-aligns a number in 9 characters with 2 decimal places — together they line up every rupee amount in a column, the way a real bill does.
💡Verified at the boundaries. 100 units charges only the first slab (₹450.00), 150 units crosses into the second (₹750.00), and 250 units reaches the third (₹1475.00) — each run through gcc -Wall -Wextra with zero warnings before this number was trusted.
02
Data & Operators
- The built-in data types, and exactly how many bytes each one costs
- Variables, constants, literals and the escape sequences hiding inside strings
- Every operator C gives you — arithmetic, relational, logical, bitwise — and the order they run in
- Implicit and explicit type conversion, then Practical 2 — interest and three ways to swap
Day 03 · Module 2 · Data & operators
Today we cover
- C's built-in data types, and what
sizeof actually measures
- Declaring and initialising variables
const versus #define — two ways to say "this never changes"
- Literals, and the escape sequences hidden inside string and character literals
- Format specifiers — matching
printf / scanf to the right type
By the end of today you can declare a variable of the right type for any value, and explain the difference between a constant the preprocessor pastes in and one the compiler actually knows about.
What a variable costs
Every type has a size, in bytes
#include <stdio.h>
int main(void)
{
printf("char %zu byte\n", sizeof(char));
printf("short %zu bytes\n", sizeof(short));
printf("int %zu bytes\n", sizeof(int));
printf("long %zu bytes\n", sizeof(long));
printf("long long %zu bytes\n", sizeof(long long));
printf("float %zu bytes\n", sizeof(float));
printf("double %zu bytes\n", sizeof(double));
printf("long double %zu bytes\n", sizeof(long double));
return 0;
}
Output
char 1 byte
short 2 bytes
int 4 bytes
long 4 bytes
long long 8 bytes
float 4 bytes
double 8 bytes
long double 16 bytes
sizeof is a compile-time operator, not a function call — it returns a size_t, an unsigned type, which is why it is printed with %zu rather than %d.
- These numbers are machine-specific. This run is from 64-bit Windows, gcc 16.1.0 (MinGW-w64 x86_64-ucrt-posix-seh). Here
long is 4 bytes; on 64-bit Linux the identical program prints 8 bytes for long, because the two platforms pick different data models for the same type.
- The C standard only guarantees a minimum range, never an exact size —
char at least 8 bits, short and int at least 16 bits, long at least 32 bits, long long at least 64 bits. Never hard-code a size in your own programs — always ask sizeof.
💡Run sizeof on your own machine before trusting any number on this slide — that is the entire point of the operator.
Naming a box in memory
A variable is a named box of a fixed type
#include <stdio.h>
int main(void)
{
int rollNo = 12; /* declared and initialised together */
int marks1, marks2, marks3; /* declared together, values later */
marks1 = 78;
marks2 = 85;
marks3 = 91;
int total = marks1 + marks2 + marks3; /* declared where first needed */
printf("Roll No: %d\n", rollNo);
printf("Marks: %d, %d, %d\n", marks1, marks2, marks3);
printf("Total: %d\n", total);
return 0;
}
Output
Roll No: 12
Marks: 78, 85, 91
Total: 254
- Declaration reserves the box and fixes its type —
int rollNo means "a box exactly big enough for an int, named rollNo."
- Initialisation gives it a value at the same moment it is declared,
= 12. You can also declare several variables of the same type in one line, int marks1, marks2, marks3;, and assign each one later.
- C lets you declare a variable anywhere before its first use, not only at the top of the block —
total is declared right where it is first needed, not earlier.
💡An uninitialised local variable holds garbage, not zero — whatever bits happened to already be sitting in that memory. Always initialise before you read.
Two ways to say "never changes"
One is text, the other is typed
#include <stdio.h>
#define PI 3.14159 /* preprocessor: text substitution */
int main(void)
{
const float gst = 0.18f; /* compiler-checked, typed, scoped */
float radius = 5.0f;
float area = PI * radius * radius;
float taxedPrice = 1000 * (1 + gst);
printf("Area of circle = %.2f\n", area);
printf("Price with GST = %.2f\n", taxedPrice);
return 0;
}
Output
Area of circle = 78.54
Price with GST = 1180.00
| #define | const |
| Handled by | Preprocessor, Stage 1 | Compiler |
| Has a type? | No — plain text | Yes — typed |
| Has scope? | No — whole file from that point | Yes — block or file, like a normal variable |
| Compiler checks it? | Never sees the name | Fully type-checked |
💡Tied to Day 1's compilation pipeline. #define PI 3.14159 is erased by the preprocessor before the compiler ever runs — every PI in the file is replaced by the literal text 3.14159. const float gst is a real variable the compiler tracks, with a type and a scope, that simply refuses any code that tries to assign to it again.
Characters that don't look like characters
A backslash means "not what it looks like"
#include <stdio.h>
int main(void)
{
printf("Tab:\tbetween\ttwo\twords\n");
printf("Newline splits->\nhere\n");
printf("Backslash: \\ Quote: \"quoted\" Apostrophe: \'A\'\n");
printf("Bell/alert: [\a] Carriage return: [\r]->overwritten\n");
printf("Backspace demo: AB\bC\n");
return 0;
}
Output
Tab: between two words
Newline splits->
here
Backslash: \ Quote: "quoted" Apostrophe: 'A'
Bell/alert: [] Carriage return: []->overwritten
Backspace demo: ABC
- A literal is a value written directly in source —
42, 3.14, 'A' and "hi" are all literals.
\n newline, \t horizontal tab, \\ a literal backslash, \" a quote mark inside a string, \' an apostrophe inside a char literal.
\a rings the terminal bell (silent or invisible on most modern terminals), \r returns the cursor to the start of the line without advancing it, \b backs the cursor up one column — each is exactly one character, however many symbols it takes to write it.
💡Why the compiler needs \" and \\ at all: a bare " inside a string literal would end the string early, and a bare \ always starts an escape sequence — both must be escaped to appear as themselves.
Telling printf and scanf what type is coming
Match the specifier to the type
| Specifier | Type | Reads/prints |
%d | int | Signed decimal integer |
%u | unsigned int | Unsigned decimal integer |
%ld | long | Signed long integer |
%f | float | Decimal, 6 places by default |
%lf | double | Only for scanf — printf uses %f for both |
%c | char | A single character |
%s | char[] | A string, up to the null terminator |
%x / %o | int | Hexadecimal / octal |
%zu | size_t | What sizeof returns |
Precision and width.
%.2f prints a float or double to exactly 2 decimal places — used on every rupee amount so far in this deck.
%9.2f right-aligns that same number in a field 9 characters wide, as Practical 1's bill did.
%-16s left-aligns a string in a field 16 characters wide.
💡A mismatched specifier is undefined behaviour, not an error. Passing an int to %f compiles and may print nonsense, or crash — gcc -Wall warns about most mismatches, which is one more reason to always build with it.
Day 04 · Module 2 · Data & operators
Today we cover
- Arithmetic and assignment operators, and where integer division catches beginners out
- Relational and logical operators — the building blocks of every future
if
- Bitwise operators, bit by bit, with the binary made visible
- Precedence and associativity — the reference table you will keep coming back to
- Type conversion and casting, then Practical 2 — interest and three ways to swap two numbers
By the end of today you can read any expression built from C's operators and predict exactly what it evaluates to, in exactly what order.
The two mistakes everyone makes first
Integer division truncates; ++ has two forms
#include <stdio.h>
int main(void)
{
int a = 7, b = 2;
float fa = 7.0f, fb = 2.0f;
printf("int division 7/2 = %d\n", a / b);
printf("float division 7.0/2.0=%.1f\n", fa / fb);
printf("modulus 7 %% 2 = %d\n", a % b);
int c = 10;
c += 5; printf("c += 5 -> %d\n", c);
c -= 3; printf("c -= 3 -> %d\n", c);
c *= 2; printf("c *= 2 -> %d\n", c);
c /= 4; printf("c /= 4 -> %d\n", c);
int x = 5;
printf("x = %d\n", x);
printf("x++ evaluates to %d\n", x++);
printf("after x++, x = %d\n", x);
printf("++x evaluates to %d\n", ++x);
printf("after ++x, x = %d\n", x);
printf("x-- evaluates to %d\n", x--);
printf("after x--, x = %d\n", x);
printf("--x evaluates to %d\n", --x);
printf("after --x, x = %d\n", x);
return 0;
}
Output
int division 7/2 = 3
float division 7.0/2.0=3.5
modulus 7 % 2 = 1
c += 5 -> 15
c -= 3 -> 12
c *= 2 -> 24
c /= 4 -> 6
x = 5
x++ evaluates to 5
after x++, x = 6
++x evaluates to 7
after ++x, x = 7
x-- evaluates to 7
after x--, x = 6
--x evaluates to 5
after --x, x = 5
- Truncation.
7 / 2 throws away the remainder and gives 3, not 3.5, because both operands are int. Make even one operand a float and the division happens in floating point.
% the modulus operator gives the remainder, and only works on integers — 7 % 2 is 1.
c += 5 is shorthand for c = c + 5. The same pattern works for -= *= /= %=.
- Pre vs post.
x++ (post-increment) hands back the old value, then increments. ++x (pre-increment) increments first, then hands back the new value — the output above shows both stages of each, so the difference is not just a rule, it is a number.
💡The mistake you will actually make: writing average = total / count; with all three as int, then wondering why a class average of 82.4 printed as 82. Fix it the way Practical 1's tax line did — cast, or declare one operand as float.
Every future if is built from these
A comparison is just a 0 or a 1
| Operator | Means |
== | Equal to |
!= | Not equal to |
> / < | Greater than / less than |
>= / <= | Greater or equal / less or equal |
&& | Logical AND — true only if both sides are true |
|| | Logical OR — true if either side is true |
! | Logical NOT — flips true to false |
C has no separate boolean type here. Every relational or logical expression evaluates to the int 1 for true or 0 for false.
Worked example — exam eligibility.
attendance = 78, marks = 42
attendance >= 75 && marks >= 40
→ 1 && 1 → 1 (eligible)
💡Short-circuit evaluation. In a && b, C never even evaluates b if a is already false — the answer is already known. The same applies to || when the left side is already true. This is why p != NULL && *p == 5 is safe: if p is null, the unsafe second half never runs.
Operating on individual bits
Six operators, one bit at a time
#include <stdio.h>
void printBits(unsigned int n)
{
for (int bit = 7; bit >= 0; bit--)
printf("%d", (n >> bit) & 1);
}
int main(void)
{
unsigned int a = 12; /* 0x0C */
unsigned int b = 10; /* 0x0A */
printf("a = 12 = "); printBits(a); printf("\n");
printf("b = 10 = "); printBits(b); printf("\n\n");
printf("a & b = "); printBits(a & b); printf(" (%d)\n", a & b);
printf("a | b = "); printBits(a | b); printf(" (%d)\n", a | b);
printf("a ^ b = "); printBits(a ^ b); printf(" (%d)\n", a ^ b);
printf("~a = "); printBits(~a); printf(" (%d)\n", ~a);
printf("a << 2 = "); printBits(a << 2); printf(" (%d)\n", a << 2);
printf("a >> 2 = "); printBits(a >> 2); printf(" (%d)\n", a >> 2);
unsigned int mask = 0x0F;
printf("\nmask = 0x0F = "); printBits(mask); printf("\n");
printf("a & mask (low nibble) = %d\n", a & mask);
return 0;
}
Output
a = 12 = 00001100
b = 10 = 00001010
a & b = 00001000 (8)
a | b = 00001110 (14)
a ^ b = 00000110 (6)
~a = 11110011 (-13)
a << 2 = 00110000 (48)
a >> 2 = 00000011 (3)
mask = 0x0F = 00001111
a & mask (low nibble) = 12
printBits prints only the low 8 bits of its argument, using (n >> bit) & 1 to peel off one bit at a time — that expression is this whole slide in miniature.
& 1 only where both bits are 1 — useful for masking, as the last line shows.
| 1 where either bit is 1. ^ (XOR) 1 where the bits differ.
~ flips every bit, including bits outside the 8 this slide prints — which is why ~a reads as a large negative number, -13, once all 32 bits of the int are accounted for.
<< / >> shift every bit left or right by the given count, filling with 0 — each shift by 1 is a multiply or divide by 2, so a << 2 is a * 4.
💡Do not confuse & with &&, or | with ||. The single-character versions work bit by bit on numbers; the doubled versions work on truth values and short-circuit. 0x0F is the same value as decimal 15 — hex is just easier to read as four bits at a time.
The reference table
Highest binds first
| Level | Operators | Meaning | Associativity |
| 1 (highest) | () [] -> . postfix ++ -- | Call, index, member access, post-inc/dec | Left → right |
| 2 | prefix ++ -- unary +/- ! ~ * & sizeof (type) | Unary: pre-inc/dec, not, complement, deref, address-of, cast | Right → left |
| 3 | * / % | Multiplicative | Left → right |
| 4 | + - | Additive | Left → right |
| 5 | << >> | Bitwise shift | Left → right |
| 6 | < <= > >= | Relational | Left → right |
| 7 | == != | Equality | Left → right |
| 8 | & | Bitwise AND | Left → right |
| 9 | ^ | Bitwise XOR | Left → right |
| 10 | | | Bitwise OR | Left → right |
| 11 | && | Logical AND | Left → right |
| 12 | || | Logical OR | Left → right |
| 13 | ?: | Ternary conditional | Right → left |
| 14 | = += -= *= /= %= &= ^= |= <<= >>= | Assignment | Right → left |
| 15 (lowest) | , | Comma | Left → right |
💡Read this table top to bottom, not left to right. A higher level always binds tighter: in 2 + 3 * 4, * (level 3) runs before + (level 4), giving 14, not 20. When in doubt, add parentheses — they cost nothing and remove all ambiguity, for you and for the next person reading the code.
When C changes a value's type for you — or you force it
Implicit happens quietly; explicit you ask for
#include <stdio.h>
int main(void)
{
int i = 5;
float f = 2.0f;
float result = i / f;
printf("5 / 2.0f = %.2f\n", result);
double pi = 3.14159;
int truncated = (int) pi;
printf("(int) 3.14159 = %d (fraction lost)\n",
truncated);
int big = 130;
signed char small = (signed char) big;
printf("(signed char) 130 = %d (overflowed)\n",
small);
int marks = 8, total = 10;
printf("(float)marks/total*100 = %.1f%%\n",
(float) marks / total * 100);
printf("marks/total*100 (no cast) = %d%%\n",
marks / total * 100);
return 0;
}
Output
5 / 2.0f = 2.50
(int) 3.14159 = 3 (fraction lost)
(signed char) 130 = -126 (overflowed)
(float)marks/total*100 = 80.0%
marks/total*100 (no cast) = 0%
- Implicit conversion.
i / f mixes an int and a float — C silently promotes i to float before dividing, so the division happens in floating point without you asking.
- Explicit casting.
(int) pi forces a double down to an int, truncating the fraction. (float) marks / total forces the division into floating point before * 100 runs — without it, integer division truncates first and the whole calculation is ruined.
- Visible data loss.
signed char only holds -128 to 127. Casting 130 into it wraps around to -126 — the value is not clamped, it overflows.
💡Cast the operand, not the result. (float) marks / total converts before dividing; (float)(marks / total) would cast an already-truncated integer and still be wrong. Where you put the parentheses changes the answer.
Module 2 practical
Simple interest, and three ways to swap
#include <stdio.h>
int main(void)
{
float principal, rate, time, interest;
printf("Enter principal (Rs): ");
scanf("%f", &principal);
printf("Enter rate (%% per year): ");
scanf("%f", &rate);
printf("Enter time (years): ");
scanf("%f", &time);
interest = (principal * rate * time) / 100;
printf("\nSimple interest = Rs %.2f\n", interest);
printf("Amount payable = Rs %.2f\n",
principal + interest);
return 0;
}
Output
Enter principal (Rs): 5000
Enter rate (% per year): 8
Enter time (years): 2
Simple interest = Rs 800.00
Amount payable = Rs 5800.00
#include <stdio.h>
int main(void)
{
int a, b, temp;
printf("Enter a and b: ");
scanf("%d %d", &a, &b);
printf("Before: a=%d, b=%d\n\n", a, b);
temp = a; a = b; b = temp; /* 1: temp */
printf("Temp var: a=%d, b=%d\n", a, b);
a = a + b; b = a - b; a = a - b; /* 2: +/- */
printf("Arithmetic: a=%d, b=%d\n", a, b);
a = a ^ b; b = a ^ b; a = a ^ b; /* 3: XOR */
printf("XOR: a=%d, b=%d\n", a, b);
return 0;
}
Output
Enter a and b: 5 9
Before: a=5, b=9
Temp var: a=9, b=5
Arithmetic: a=5, b=9
XOR: a=9, b=5
💡Be honest about the last two. The arithmetic swap and the XOR swap are interview curiosities, not code you should actually write — the temp-variable version is clearer, no slower on any modern compiler, and does not risk overflow. Worse, the XOR swap breaks if a and b are the same variable (swap(&x, &x)): the first line a = a ^ b becomes x = x ^ x, which is always 0 — the variable is silently zeroed instead of left unchanged. Know the trick for the viva; use the temp variable in real code.
03
Input & Output
printf in depth — width, precision and the flags that align a table
scanf, its return value, and why the & is never optional
- Character I/O, one character at a time, with
getchar and putchar
- The input buffer trap, then Practical 3 — and this course's first Practice set and timed Test
Day 05 · Module 3 · Input & output
Today we cover
printf's width, precision, and the - + 0 flags
scanf with & — and its return value, the one thing almost nobody is taught
- Reading and echoing characters one at a time with
getchar / putchar
- The input buffer trap — the leftover newline that breaks the very next
fgets
- Practical 3 — a student mark sheet, then this course's first Practice set and its first timed Test
By the end of today you can format any output to the exact width and precision you want, read input safely by checking what scanf actually did, and explain why the buffer trap happens before it happens to you.
Width, precision and the flags
Making printf line up a table
#include <stdio.h>
int main(void)
{
printf("%-12s%8s\n", "Item", "Price");
printf("%-12s%8.2f\n", "Pen", 12.5);
printf("%-12s%8.2f\n", "Notebook", 45.0);
printf("%-12s%8.2f\n", "Eraser", 3.75);
printf("\n");
printf("[%5d]\n", 42);
printf("[%-5d]\n", 42);
printf("[%05d]\n", 42);
printf("[%+d] [%+d]\n", 42, -42);
printf("[%8.3f]\n", 3.14159);
return 0;
}
Output
Item Price
Pen 12.50
Notebook 45.00
Eraser 3.75
[ 42]
[42 ]
[00042]
[+42] [-42]
[ 3.142]
- Width. The number right after
%, as in %8s or %8.2f, sets the minimum field width. C pads with spaces on the left by default — that is what right-aligns the "Price" column above.
- flag left-aligns inside that width instead. %-12s pads the item name with spaces on the right, so every row's price column starts in the same place.
- Precision.
%.2f fixes exactly two digits after the decimal point, however many the value actually has — 45.0 becomes 45.00.
0 flag pads with leading zeros instead of spaces, and only makes sense together with a width: %05d on 42 gives 00042.
+ flag always shows the sign, even on a positive number — useful wherever the sign itself is the information, such as a profit/loss figure.
💡Order matters. A specifier is written % flags, then width, then .precision, then the type letter — %-8.2f is the - flag, width 8, precision .2, type f. Get the order wrong and printf does not warn you — it just prints something else.
Telling scanf where to store, and asking whether it worked
scanf needs an address, not a value
#include <stdio.h>
int main(void)
{
int age, ok;
printf("Enter your age: ");
ok = scanf("%d", &age);
if (ok == 1) {
printf("scanf converted %d item(s). Age read as %d.\n", ok, age);
} else {
printf("scanf converted %d item(s). That was not a number.\n", ok);
}
return 0;
}
Output — valid input
Enter your age: 20
scanf converted 1 item(s). Age read as 20.
Output — typing letters instead
Enter your age: abc
scanf converted 0 item(s). That was not a number.
- The
&. scanf("%d", &age) passes the address of age, not its value — scanf must be told where in memory to store what it reads. Forgetting the & is one of the most common first-month C bugs, and it often does not even fail to compile, only crash or store garbage.
- The return value.
scanf returns the number of items it successfully converted, not whether the call "worked" in general. Almost nobody is taught this, and it is the only reliable way to notice that the user typed something that was not a number.
- Above,
ok stores that return value, so the program can tell real input from bad input instead of silently trusting whatever ended up in age.
💡An unchecked scanf is a trap for later. If scanf("%d", &age) fails to convert, it leaves age completely untouched — whatever was already in that memory before the call. A program that never checks the return value will happily carry on with that garbage value as if it were real input.
Reading and writing one character at a time
getchar and putchar
#include <stdio.h>
int main(void)
{
int ch;
printf("Type text, end with Ctrl+D (Ctrl+Z on Windows):\n");
while ((ch = getchar()) != EOF) {
putchar(ch);
}
printf("\n-- end of input --\n");
return 0;
}
Output
Type text, end with Ctrl+D (Ctrl+Z on Windows):
Hi C
Hi C
-- end of input --
getchar reads exactly one character from standard input and hands it back as an int, not a char.
- Why an
int. EOF is a special value outside the range of every possible char (typically -1). Storing getchar's result in a char can make a real character look like EOF and end the loop early — always declare the variable as int.
putchar writes one character to standard output — together with getchar this is the simplest possible echo loop, one character in, one character out.
💡EOF is not a key on the keyboard. It is the signal "no more input is coming" — typed as Ctrl+D on Linux/macOS or Ctrl+Z then Enter on Windows, or reached automatically when reading from a file or a pipe.
The bug every beginner hits, once
Why the very next fgets reads nothing
#include <stdio.h>
int main(void)
{
int n;
char name[40];
printf("How many students? ");
scanf("%d", &n);
printf("Class teacher's name: ");
fgets(name, sizeof(name), stdin);
printf("Students: %d, Teacher: %s\n", n, name);
return 0;
}
Output — broken
How many students? 30
Class teacher's name:
Students: 30, Teacher:
#include <stdio.h>
int main(void)
{
int n, ch;
char name[40];
printf("How many students? ");
scanf("%d", &n);
while ((ch = getchar()) != '\n' && ch != EOF)
;
printf("Class teacher's name: ");
fgets(name, sizeof(name), stdin);
printf("Students: %d, Teacher: %s\n", n, name);
return 0;
}
Output — fixed
How many students? 30
Class teacher's name: Mrs Sharma
Students: 30, Teacher: Mrs Sharma
💡What actually happens. Typing 30 and pressing Enter sends "30\n" to standard input. scanf("%d", &n) reads only the digits and leaves that '\n' sitting in the buffer, unread. The very next input call, fgets, does not wait for the teacher's name at all — it immediately reads that leftover newline as a complete, empty line, and returns. The fix is the drain loop: while ((ch = getchar()) != '\n' && ch != EOF); reads and throws away every character up to and including that stray newline, so the following fgets starts clean. Do this after every scanf that will be followed by fgets.
Module 3 practical
Five subjects, one aligned report card
#include <stdio.h>
int main(void)
{
char name[40];
int eng, math, sci, sst, hindi, total;
float pct;
char grade;
printf("Student name: ");
fgets(name, sizeof(name), stdin);
printf("Marks (out of 100 each) - English Maths Science SST Hindi: ");
scanf("%d %d %d %d %d", &eng, &math, &sci, &sst, &hindi);
total = eng + math + sci + sst + hindi;
pct = (float) total / 5;
if (pct >= 90) grade = 'A';
else if (pct >= 75) grade = 'B';
else if (pct >= 60) grade = 'C';
else if (pct >= 33) grade = 'D';
else grade = 'F';
printf("\n---- Mark Sheet ----\n");
printf("Name: %s", name);
printf("%-10s%6d\n", "English", eng);
printf("%-10s%6d\n", "Maths", math);
printf("%-10s%6d\n", "Science", sci);
printf("%-10s%6d\n", "SST", sst);
printf("%-10s%6d\n", "Hindi", hindi);
printf("%-10s%6d\n", "Total", total);
printf("Percentage: %.2f%%\n", pct);
printf("Grade: %c\n", grade);
return 0;
}
Output
Student name: Ravi Kumar
Marks (out of 100 each) - English Maths Science SST Hindi: 85 92 78 88 95
---- Mark Sheet ----
Name: Ravi Kumar
English 85
Maths 92
Science 78
SST 88
Hindi 95
Total 438
Percentage: 87.60%
Grade: B
fgets reads the name first, before any scanf call, so there is no leftover newline to trap it — exactly the fix from the previous slide, applied by simply choosing the right order.
- One
scanf, five values. "%d %d %d %d %d" reads all five marks in a single call; the spaces between specifiers let scanf skip over any amount of whitespace the user types between numbers.
%-10s%6d left-aligns the subject name in a 10-character field and right-aligns its mark in a 6-character field — the same width trick from printf in depth, now building a real report card.
💡The percentage cast. pct = (float) total / 5; casts total before dividing, exactly as Practical 2's tax line did — without it, five integer marks divided by the integer 5 would truncate the percentage to a whole number.
04
Control Flow
if, if-else and else-if — and the nested-if traps that catch almost everyone once
switch and deliberate fall-through, then why goto is a museum piece
while, do-while and for — three shapes for the same idea, and when to reach for each
break, continue, nested loops and star patterns, dry-running a loop by hand, then Practical 4 — and this course's second Practice set and Test
Day 06 · Module 4 · Control flow
Today we cover
if, if-else and the else-if ladder — and why the order of the conditions decides the answer
- Nested
if without braces — the dangling-else trap, and the classic if (x = 5) typo
switch, grouped cases and fall-through — on purpose, and by accident
goto — what it does, and why this course will not use it again
By the end of today you can build a correct else-if ladder, brace every nested if on sight, and explain exactly what a missing break does inside a switch.
Branching on a condition
The ladder checks top to bottom, and stops
#include <stdio.h>
int main(void)
{
int marks = 82;
if (marks >= 33)
printf("Grade: D\n");
else if (marks >= 60)
printf("Grade: C\n");
else if (marks >= 75)
printf("Grade: B\n");
else if (marks >= 90)
printf("Grade: A\n");
else
printf("Grade: F\n");
return 0;
}
Output — wrong order
Grade: D
#include <stdio.h>
int main(void)
{
int marks = 82;
if (marks >= 90)
printf("Grade: A\n");
else if (marks >= 75)
printf("Grade: B\n");
else if (marks >= 60)
printf("Grade: C\n");
else if (marks >= 33)
printf("Grade: D\n");
else
printf("Grade: F\n");
return 0;
}
Output — correct order
Grade: B
💡An else-if ladder stops at the first true condition and never looks at the rest. With marks = 82, testing >= 33 first is true immediately, so the ladder prints D and never even reaches the >= 90 or >= 75 branches that were actually meant to catch it. The fix is not a different condition — it is the same conditions in the opposite order, most restrictive first, so a mark of 82 only passes the >= 90 test if it is truly 90 or above.
Two bugs almost every beginner writes once
One equals sign, one missing brace
#include <stdio.h>
int main(void)
{
int x = 0;
if (x = 5) {
printf("condition was true, x is now %d\n", x);
} else {
printf("condition was false, x is now %d\n", x);
}
return 0;
}
Compiler warning
trap_assign.c:7:9: warning: suggest parentheses around
assignment used as truth value [-Wparentheses]
Output
condition was true, x is now 5
#include <stdio.h>
int main(void)
{
int a = 3, b = -7;
if (a > 0)
if (b > 0)
printf("both positive\n");
else
printf("a is not positive\n");
printf("done\n");
return 0;
}
Compiler warning
trap_else.c:7:8: warning: suggest explicit braces to
avoid ambiguous 'else' [-Wdangling-else]
Output
a is not positive
done
💡= is not ==. if (x = 5) does not compare — it assigns 5 to x and then tests that assignment's value, which is always 5, always true. GCC warns, but the program still compiles and runs, silently wrong; the fix is if (x == 5). An else always binds to the nearest unmatched if. Above, with a = 3 (positive) and b = -7 (not), the writer meant the else to answer the outer if (a > 0), but with no braces it binds to the inner if (b > 0) instead — so the program wrongly claims a is not positive. Brace every nested if, always, even a one-line body.
Grouping cases on purpose
Falling through is a choice, not an accident
#include <stdio.h>
int main(void)
{
int day = 6;
switch (day) {
case 1: case 2: case 3: case 4: case 5:
printf("Weekday - school is open\n");
break;
case 6: case 7:
printf("Weekend - school is closed\n");
break;
default:
printf("Not a valid day number\n");
}
return 0;
}
Output
Weekend - school is closed
- Stacked case labels.
case 1: through case 5: with no statement between them all fall into the same printf — this is fall-through used deliberately, to group days that share an outcome.
break leaves the switch the moment a matching group has run its statement, so execution never spills into the next group below it.
default catches any value that matched no case at all — here, anything outside 1–7. It does not need its own break since nothing follows it.
💡A missing break is C's default, and it is almost always a bug. Leave one out between two groups that were not meant to share code, and control quietly falls into the next case's statements too — no error, no warning by default, just the wrong output. Every case needs a break (or a return) unless the fall-through is intentional and, ideally, commented.
A jump instruction, dressed as a keyword
It works. That is not the same as good
#include <stdio.h>
int main(void)
{
int i = 1;
top:
if (i > 5)
goto done;
printf("%d ", i);
i++;
goto top;
done:
printf("\ndone\n");
return 0;
}
#include <stdio.h>
int main(void)
{
int i;
for (i = 1; i <= 5; i++) {
printf("%d ", i);
}
printf("\ndone\n");
return 0;
}
💡Same output, very different program. goto.c rebuilds a loop by hand out of a label and a jump — it works, but nothing in the text tells you it is a loop until you trace the jumps yourself, and in a longer program goto can jump anywhere, forwards or backwards, in or out of blocks, which is exactly what makes hand-built control flow like this hard to read and easy to break. goto_better.c says the same thing with a for loop, whose start, end and step are visible in one line. This course avoids goto by convention, the way almost every real C codebase does — not because it is banned outright, but because the two cases where it genuinely earns its place are narrow and worth naming: escaping a deeply nested loop, where a single break or continue only affects the innermost loop and reaching an outer one otherwise needs a flag variable checked at every level; and centralised cleanup, where a function that has acquired several resources jumps every failure to one cleanup: label instead of repeating the release code at each exit point — a pattern C leans on precisely because it has no destructors or finally. Outside those two, reach for the structured loop every time.
Day 07 · Module 4 · Control flow
Today we cover
while and do-while — entry-controlled versus exit-controlled, and why that difference matters
for's three clauses in full, and how to choose between the three loop shapes
break, continue, nested loops and four classic star patterns
- Dry-running a loop by hand with a trace table, then Practical 4 — a menu-driven calculator, and this course's second Practice set and timed Test
By the end of today you can pick the right loop for a job without guessing, trace what a loop does line by line on paper, and build a menu that keeps asking until the user quits.
Checking before the body runs, or after
The same sum, three shapes
#include <stdio.h>
int main(void)
{
int n = 5, i, sum;
sum = 0; i = 1;
while (i <= n) { sum += i; i++; }
printf("while : sum 1 to %d = %d\n", n, sum);
sum = 0; i = 1;
do { sum += i; i++; } while (i <= n);
printf("do-while : sum 1 to %d = %d\n", n, sum);
sum = 0;
for (i = 1; i <= n; i++) sum += i;
printf("for : sum 1 to %d = %d\n", n, sum);
return 0;
}
Output
while : sum 1 to 5 = 15
do-while : sum 1 to 5 = 15
for : sum 1 to 5 = 15
while is entry-controlled — it tests the condition before every iteration, including the first. If the condition is false to start with, the body never runs at all.
do-while is exit-controlled — the body runs first, and the condition is only tested after, at the bottom. The body always runs at least once, even if the condition was false from the start.
- All three loops above compute the identical sum — the difference is never what they compute here, only when the condition gets checked.
💡Picture n = 0. while (i <= n) would test 1 <= 0, find it false immediately, and skip the body completely — the while and for versions would both print a sum of 0 without ever entering the loop. do-while cannot do that: its body always executes once before the condition is even looked at. That one guarantee is exactly why Practical 4's menu, later today, is built on do-while — a menu must be shown at least once, before there is anything to check.
Three clauses, one line
Init; condition; update
#include <stdio.h>
int main(void)
{
int i, j;
for (i = 1, j = 5; i <= 5; i++, j--) {
printf("i=%d j=%d\n", i, j);
}
for (i = 0; i < 10; i += 2) {
printf("%d ", i);
}
printf("\n");
return 0;
}
Output
i=1 j=5
i=2 j=4
i=3 j=3
i=4 j=2
i=5 j=1
0 2 4 6 8
- Init
i = 1, j = 5 runs exactly once, before the loop starts. The comma lets you initialise more than one variable — here, two counters moving in opposite directions.
- Condition
i <= 5 is tested before every iteration, first loop included. The moment it is false, the loop ends and the body does not run again.
- Update
i++, j-- runs after each iteration's body, right before the condition is checked again — not once, not at the start.
- The second loop shows the update clause is not stuck at
i++: i += 2 steps by two just as easily.
💡All three clauses are optional; the two semicolons are not. for (;;) is a legal, deliberate infinite loop with no init, condition or update at all — you will see it later paired with a break inside. Forgetting the update clause by accident, though, is the most common way a beginner writes an infinite loop unintentionally: the condition never changes, so it is never false.
Three shapes, one decision
Let the problem pick the loop
| Loop | Use it when | One-line example |
for | The number of iterations is known before the loop starts — a fixed count, an array length, a row of a table. | for (i = 0; i < n; i++) |
while | You loop until some condition changes, and you cannot say in advance how many times that will take. | while (scanf("%d", &n) == 1) |
do-while | The body must run at least once, before there is anything to check — a menu, or a prompt that has to appear before its own input is validated. | do { ... } while (choice != 5); |
💡Any of the three can technically do any of these jobs — that is not the point. Reaching for the shape whose built-in guarantee matches the problem tells the next reader, immediately, what kind of loop this is without them tracing the condition by hand. A for loop over a known range, a while loop that waits for a sentinel, a do-while menu — each one announces its own shape before you read a single line of its body.
Leaving early, or skipping ahead
break exits; continue skips
#include <stdio.h>
int main(void)
{
int n = 37, i;
for (i = 2; i <= n / 2; i++) {
if (n % i == 0) {
printf("First factor found: %d\n", i);
break;
}
}
if (i > n / 2) {
printf("No factor found up to n/2 - %d is prime\n", n);
}
return 0;
}
Output
No factor found up to n/2 - 37 is prime
#include <stdio.h>
int main(void)
{
int i;
printf("Odd numbers from 1 to 10: ");
for (i = 1; i <= 10; i++) {
if (i % 2 == 0) {
continue;
}
printf("%d ", i);
}
printf("\n");
return 0;
}
Output
Odd numbers from 1 to 10: 1 3 5 7 9
💡break leaves the loop immediately and jumps to the statement right after it — above, the moment a factor is found, there is no reason to keep dividing, so the search stops there. continue does not leave the loop; it skips straight to the next iteration, so the rest of that one pass through the body is abandoned but the loop itself carries on — above, an even i is skipped, not printed, and the loop moves on to the next value. Both jump control flow around the normal top-to-bottom read of the body, so use them where they genuinely simplify a search or a filter, not as a habit.
One loop for the row, one for the column
Four patterns, one idea
#include <stdio.h>
int main(void)
{
int n = 5, i, j, num;
printf("Right triangle:\n");
for (i = 1; i <= n; i++) {
for (j = 1; j <= i; j++) printf("*");
printf("\n");
}
printf("\nInverted triangle:\n");
for (i = n; i >= 1; i--) {
for (j = 1; j <= i; j++) printf("*");
printf("\n");
}
printf("\nPyramid:\n");
for (i = 1; i <= n; i++) {
for (j = 1; j <= n - i; j++) printf(" ");
for (j = 1; j <= 2 * i - 1; j++) printf("*");
printf("\n");
}
printf("\nFloyd's triangle:\n");
num = 1;
for (i = 1; i <= n; i++) {
for (j = 1; j <= i; j++) printf("%d ", num++);
printf("\n");
}
return 0;
}
Output
Right triangle:
*
**
***
****
*****
Inverted triangle:
*****
****
***
**
*
Pyramid:
*
***
*****
*******
*********
Floyd's triangle:
1
2 3
4 5 6
7 8 9 10
11 12 13 14 15
- Outer loop, inner loop. In every pattern here the outer
for (i ...) picks the row, and the inner for (j ...) decides what fills that row — the inner loop runs to completion once for every single value of i.
- The pyramid uses two inner loops. One prints the leading spaces, the other prints the stars — run one after the other on the same row, not nested inside each other.
num++ in Floyd's triangle keeps counting upward across every row and never resets — the one pattern here where the printed value is not derived from i or j directly.
💡The pyramid's space count is the part everyone gets wrong first. Row i needs n - i leading spaces, not i — the last row (i = n) needs zero spaces and the widest row of stars, so the space count must shrink as i grows, exactly opposite to the star count, which is 2i - 1 and grows.
Tracing a loop by hand, one iteration at a time
Reversing 1234, step by step
#include <stdio.h>
int main(void)
{
int n = 1234, digit, rev = 0;
while (n != 0) {
digit = n % 10;
rev = rev * 10 + digit;
n = n / 10;
}
printf("Reversed number = %d\n", rev);
return 0;
}
Output
Reversed number = 4321
| n | digit | rev |
| 1234 | — | 0 |
| 123 | 4 | 4 |
| 12 | 3 | 43 |
| 1 | 2 | 432 |
| 0 | 1 | 4321 |
💡The first row is the state before the loop body has run even once — n still holds the full number and rev is still 0. Every row after that shows n, digit and rev once that pass's three statements have all executed, in order: peel off the last digit, fold it into rev, then chop it off n. The condition n != 0 decides whether there is a next row at all — the moment n reaches 0 the loop stops, with rev holding the fully reversed number. This is exactly the table a dry-run question on an exam paper expects.
Module 4 practical
A menu that keeps asking, until you quit
#include <stdio.h>
int main(void)
{
int choice, ok;
float a, b;
char junk;
do {
printf("\n1.Add 2.Sub 3.Mul 4.Div 5.Quit\nChoice: ");
ok = scanf("%d", &choice);
if (ok != 1) {
printf("Not a number.\n");
while ((junk = getchar()) != '\n' && junk != EOF)
;
continue;
}
if (choice == 5) { printf("Goodbye.\n"); break; }
if (choice < 1 || choice > 5) { printf("Choose 1-5.\n"); continue; }
printf("Enter two numbers: ");
scanf("%f %f", &a, &b);
switch (choice) {
case 1: printf("Result: %.2f\n", a + b); break;
case 2: printf("Result: %.2f\n", a - b); break;
case 3: printf("Result: %.2f\n", a * b); break;
case 4:
if (b == 0)
printf("Cannot divide by zero.\n");
else
printf("Result: %.2f\n", a / b);
break;
}
} while (choice != 5);
return 0;
}
Output
1.Add 2.Sub 3.Mul 4.Div 5.Quit
Choice: 1
Enter two numbers: 10 4
Result: 14.00
1.Add 2.Sub 3.Mul 4.Div 5.Quit
Choice: x
Not a number.
1.Add 2.Sub 3.Mul 4.Div 5.Quit
Choice: 4
Enter two numbers: 8 0
Cannot divide by zero.
1.Add 2.Sub 3.Mul 4.Div 5.Quit
Choice: 5
Goodbye.
do-while shows the menu before checking anything — exactly the case from the earlier slide where the body has to run at least once, before there is any input to validate.
- Checking
scanf's return value. ok = scanf("%d", &choice) and then if (ok != 1) is the same check from Day 5 — typing x instead of a number is caught here instead of quietly leaving choice holding garbage, and the drain loop clears the bad input from the buffer before the menu shows again.
- The division guard.
if (b == 0) inside case 4 stops a crash before it happens — dividing by zero on a float would not even crash, it would silently print inf, so the guard is what makes the program trustworthy.
💡Out-of-range and not-a-number are two different problems, checked two different ways. choice < 1 || choice > 5 catches a valid number that is simply the wrong one, like 9; the earlier ok != 1 check catches input that was never a number at all. Confusing the two, or checking only one, is exactly how a "robust" menu still crashes on the first bad keystroke.
05
Functions & Recursion
- Breaking a long
main into named, testable pieces — declaration, definition, call
- Call by value, scope, and the four storage classes:
auto, static, register, extern
- Recursion — how the call stack actually works, and an honest look at when a loop is the better answer
- Practical 5 — a three-file math utility library, this course's first real multi-file build
Day 08 · Module 5 · Functions
Today we cover
- Why a 300-line
main is a bug waiting to happen, and how functions fix it
- Declaration, definition and call — and why C needs the prototype above
main
- Parameters versus arguments, and what call by value really copies
- Scope — local and global — and the four storage classes:
auto, static, register, extern
By the end of today you can split a program across several functions, predict exactly which variables each one can see, and explain why a swap function that looks correct still fails.
One idea, one name, one place
Write it once, call it as often as you like
#include <stdio.h>
float average(float a, float b)
{
return (a + b) / 2.0f;
}
int main(void)
{
printf("Average of 10, 20 = %.2f\n", average(10, 20));
printf("Average of 55, 65 = %.2f\n", average(55, 65));
printf("Average of 8, 12 = %.2f\n", average(8, 12));
return 0;
}
Output
Average of 10, 20 = 15.00
Average of 55, 65 = 60.00
Average of 8, 12 = 10.00
- No repetition. Without
average, the same (a + b) / 2.0f expression would be typed out, and possibly mistyped, three separate times.
- One name for one idea.
average(10, 20) reads as what it does, not how it does it — the caller does not need to know the formula at all.
- Testable in isolation. A function can be checked on its own, with known inputs and a known expected output, before it is ever wired into a bigger program.
💡A main that keeps growing is a warning sign. Every time the same few lines get copied and pasted with small changes, that is a function waiting to be written. Smaller, named pieces are easier to read, easier to test, and easier to fix — a bug in average only needs fixing in one place, not three.
Three different moments, three different jobs
C reads top to bottom, once
#include <stdio.h>
int cube(int n);
int main(void)
{
int x = 5;
printf("cube(%d) = %d\n", x, cube(x));
return 0;
}
int cube(int n)
{
return n * n * n;
}
- Declaration (prototype).
int cube(int n); above main tells the compiler cube's name, return type and parameter types — nothing about how it works yet.
- Definition. The block after
main, with a body in { }, is where cube actually does its work.
- Call.
cube(x) inside main is where the function actually runs, using whatever value x holds at that moment.
💡Why the prototype is needed at all. C compiles top to bottom in a single pass. By the time it reaches cube(x) inside main, it has not yet seen cube's real definition below — without the prototype, the compiler would not know cube exists, what it returns, or what to pass it. Delete that one line and try compiling: GCC refuses, with implicit declaration of function 'cube'.
The vocabulary examiners actually ask for
One is a name, the other is a value
#include <stdio.h>
int power(int base, int exp);
int main(void)
{
int b = 2, e = 10;
printf("%d^%d = %d\n", b, e, power(b, e));
return 0;
}
int power(int base, int exp)
{
int result = 1;
for (int i = 0; i < exp; i++)
result *= base;
return result;
}
- Parameter.
base and exp in int power(int base, int exp) are parameters — the named variables a function's definition declares to receive values.
- Argument.
b and e passed at power(b, e) are arguments — the actual values supplied at the call.
- Order matters, names do not. The argument
b is copied into parameter base only because it is first; the parameter could have been named anything at all.
💡Exam vocabulary. "Parameter" and "argument" are often used loosely in conversation, but Indian board and university papers mark them as distinct terms: the parameter lives in the function's own definition, the argument is what the caller hands over. Get the direction right — a parameter receives, an argument supplies.
What actually gets copied
C copies, always
#include <stdio.h>
void swap(int a, int b)
{
int t = a;
a = b;
b = t;
}
int main(void)
{
int x = 10, y = 20;
swap(x, y);
printf("x = %d, y = %d\n", x, y);
return 0;
}
swap receives copies of x and y, stored in its own local parameters a and b.
- Inside
swap, a and b really do swap — but they are swapping two copies that live only for the duration of the call, then vanish.
x and y in main were never touched. The function looks correct and compiles cleanly, yet it fails at the one job it was written for.
💡Remember this failure — it is the whole reason pointers exist. Every argument in C is passed by value: the function gets a copy, never the original variable. There is no way to write a working swap with plain int parameters, no matter how it is rearranged. In Module 7, swap will be rewritten to take addresses instead of values — that one change is what finally lets a function reach back and modify its caller's variables.
Where a name is visible
A local name hides a global one
#include <stdio.h>
int count = 100;
void show(void)
{
int count = 5;
printf("inside show : local count = %d\n", count);
}
int main(void)
{
printf("in main, before call : count = %d\n", count);
show();
printf("in main, after call : count = %d\n", count);
count = 200;
printf("in main, after change: count = %d\n", count);
return 0;
}
Output
in main, before call : count = 100
inside show : local count = 5
in main, after call : count = 100
in main, after change: count = 200
- Global scope.
int count = 100;, declared outside every function, is visible to every function in the file, from the point it is declared onward.
- Local scope.
show's own int count = 5; exists only inside show, from that line until show's closing brace — then it is gone.
- Shadowing. Inside
show, the local count hides the global one completely; the global is unreachable there by that name at all.
💡The global was never touched by show. show's count = 5 creates and modifies its own local copy; when main checks its global count right after the call, it is still 100, unaffected. Only main's own assignment, count = 200, changes the global — because main has no local count of its own to shadow it with.
How long a variable lives, and who can see it
Four keywords, two questions
| Keyword | Storage duration | Scope | Linkage |
auto | Created on entry to the block, destroyed on exit — the default for every local variable | Local to its block | None — every block's own copy is a separate object |
static | Created once, survives for the whole program run | Local to its block (or to the file, if declared outside every function) | None inside a function; internal if declared at file scope — shared only within that one file |
register | Created on entry, destroyed on exit — a hint to keep it in a fast CPU register | Local to its block | None |
extern | Same as the object it refers to — almost always the whole program run | From its declaration to the end of the file (or block) it appears in | External — refers to the same one object in every file that declares it |
Default value if never explicitly initialised: auto and register hold garbage; static and extern are guaranteed 0.
💡Scope and linkage are not the same question. Scope asks where in the source a name is visible; linkage asks whether two declarations of that name, possibly in different files, refer to the same object. An extern declaration's own scope is just the file it sits in — it is linkage, not scope, that lets it reach across files to the one real definition.
⚠register in practice. Modern compilers allocate registers far better than a programmer's guess, so the hint is largely ignored today — it survives mostly in older code and exam papers. The one restriction the standard still enforces: you cannot take the address of a register variable. int *p = &x; on a register int x fails to compile; GCC refuses it outright with error: address of register variable 'x' requested.
#include <stdio.h>
void track(void)
{
static int calls = 0;
int local = 0;
calls++;
local++;
printf("call: static calls = %d, local = %d\n", calls, local);
}
int main(void)
{
track();
track();
track();
return 0;
}
Output
call: static calls = 1, local = 1
call: static calls = 2, local = 1
call: static calls = 3, local = 1
💡local resets to 0 on every call, since it is auto by default. calls is created only once, the first time track runs — every later call finds it already holding whatever it was left at, which is exactly how it keeps counting up.
#include <stdio.h>
int total = 0;
void add_to_total(int n)
{
total += n;
printf("added %d, running total = %d\n", n, total);
}
#include <stdio.h>
extern int total;
void add_to_total(int n);
int main(void)
{
add_to_total(10);
add_to_total(25);
printf("final total in main = %d\n", total);
return 0;
}
$ gcc main.c helper.c -o app && ./app
added 10, running total = 10
added 25, running total = 35
final total in main = 35
💡This is the course's first multi-file build. total is defined exactly once, in helper.c. main.c never defines it — its extern int total; only tells the compiler "this variable exists somewhere, trust me, link it in later." Compiling both files together, gcc main.c helper.c -o app, is what lets the linker resolve that promise, matching main.c's extern declaration to helper.c's real definition so both files share the one total.
Day 09 · Module 5 · Functions
Today we cover
- How a function calling itself can solve a problem — the base case and the recursive case
- The call stack — what actually happens in memory while a recursive call is unwinding
- Recursion versus iteration, honestly — where each one genuinely wins
- Practical 5 — a math utility library split across three files, this course's first real multi-file build
By the end of today you can write a recursive function with a correct base case, trace its call stack by hand, and explain when a loop would have been the better choice.
A function that calls itself
Every call needs a way to stop
#include <stdio.h>
long factorial(int n)
{
if (n <= 1) return 1;
return n * factorial(n - 1);
}
int fibonacci(int n)
{
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
int gcd(int a, int b)
{
if (b == 0) return a;
return gcd(b, a % b);
}
int main(void)
{
printf("factorial(6) = %ld\n", factorial(6));
printf("fibonacci(8) = %d\n", fibonacci(8));
printf("gcd(48, 18) = %d\n", gcd(48, 18));
return 0;
}
Output
factorial(6) = 720
fibonacci(8) = 21
gcd(48, 18) = 6
- Base case. The condition that stops the chain —
n <= 1 for factorial and fibonacci, b == 0 for gcd. It answers directly, with no further call.
- Recursive case. Everything else — the function calls itself with an argument that is closer to the base case than the one it received.
fibonacci calls itself twice per call, branching into a tree rather than a single chain; factorial and gcd each call themselves once, in a straight line.
💡Every recursive function needs a base case — no exceptions. Delete the if (n <= 1) return 1; line from factorial and the calls never stop: factorial(1) calls factorial(0), which calls factorial(-1), and so on forever. Each call still needs its own space in memory to run, and that space runs out — the program crashes with a stack overflow. The exact message is platform-specific, but the cause is always the same: a base case that was missing, or unreachable.
Where a pending call waits
Four frames for factorial(4)
| Call | Returns | Value |
factorial(4) | 4 * factorial(3) | — waiting |
factorial(3) | 3 * factorial(2) | — waiting |
factorial(2) | 2 * factorial(1) | — waiting |
factorial(1) | base case | 1 |
factorial(2) | 2 * 1 | 2 |
factorial(3) | 3 * 2 | 6 |
factorial(4) | 4 * 6 | 24 |
Seven rows, but only four frames on the stack: factorial(4), (3) and (2) each appear twice — once pushed, once popped — while factorial(1), the base case, resolves instantly and needs only one row.
Pushing. Each call to factorial opens a new stack frame — its own private copy of n and its own place to resume once the call it is waiting on returns. factorial(4) cannot finish its multiplication until factorial(3) returns a value, so it waits, and the stack grows: four frames deep before anything returns.
Unwinding. factorial(1) hits the base case and returns 1 immediately, no further call needed. That answer lets factorial(2)'s pending multiplication finally run, which lets factorial(3)'s run, which lets factorial(4)'s run — each return pops one frame off the stack.
💡Every frame costs real memory. Four frames for factorial(4) is nothing, but a recursive call with no base case, or one whose base case is never reached, keeps pushing new frames forever — the stack has a fixed size, and once it is exhausted the program crashes with a stack overflow. Depth is not free.
Two honest trade-offs, not a verdict
Same answer, different cost
long factorial(int n)
{
if (n <= 1) return 1;
return n * factorial(n - 1);
}
Output
factorial(6) = 720
long factorial(int n)
{
long result = 1;
for (int i = 2; i <= n; i++)
result *= i;
return result;
}
Output
factorial(6) = 720
💡Identical output, very different memory use. fact_rec.c opens a new stack frame for every call — six frames for factorial(6), growing with n. fact_iter.c uses one result variable and one loop, in constant stack space no matter how large n gets. For a plain countdown to a base case like this, the loop is strictly cheaper and just as clear — there is no real argument for the recursive version here.
#include <stdio.h>
void hanoi(int n, char from, char aux, char to)
{
if (n == 0) return;
hanoi(n - 1, from, to, aux);
printf("Move disc %d from %c to %c\n", n, from, to);
hanoi(n - 1, aux, from, to);
}
int main(void)
{
hanoi(3, 'A', 'B', 'C');
return 0;
}
Output
Move disc 1 from A to C
Move disc 2 from A to B
Move disc 1 from C to B
Move disc 3 from A to C
Move disc 1 from B to A
Move disc 2 from B to C
Move disc 1 from A to C
hanoi(n, ...) moves n - 1 discs out of the way, moves disc n itself, then moves those n - 1 discs back onto it — the code is the problem's own definition, almost word for word.
- Writing this iteratively is possible, but it needs an explicit stack data structure to track what the call stack was doing for free — the recursive version is shorter and, once you trust it, easier to read.
💡This is where recursion earns its keep. Tower of Hanoi has no natural loop — the problem itself is defined in terms of a smaller version of itself, so a recursive solution mirrors it directly. Neither shape is universally better: pick recursion when the problem's own definition is recursive, and a loop when it genuinely is not, remembering that recursion always costs a stack frame per call and can overflow on deep input.
Module 5 practical
One header, one library, three files
#ifndef MATHLIB_H
#define MATHLIB_H
int is_prime(int n);
int gcd(int a, int b);
long power(int base, int exp);
long factorial(int n);
int reverse_digits(int n);
#endif
#include "mathlib.h"
int is_prime(int n)
{
int i;
if (n < 2) return 0;
for (i = 2; i * i <= n; i++)
if (n % i == 0) return 0;
return 1;
}
int gcd(int a, int b)
{
if (b == 0) return a;
return gcd(b, a % b);
}
long power(int base, int exp)
{
if (exp == 0) return 1;
return base * power(base, exp - 1);
}
long factorial(int n)
{
if (n <= 1) return 1;
return n * factorial(n - 1);
}
int reverse_digits(int n)
{
int rev = 0;
while (n != 0) { rev = rev * 10 + n % 10; n /= 10; }
return rev;
}
#include <stdio.h>
#include "mathlib.h"
int main(void)
{
printf("is_prime(29) = %d\n", is_prime(29));
printf("is_prime(30) = %d\n", is_prime(30));
printf("gcd(84, 30) = %d\n", gcd(84, 30));
printf("power(2, 10) = %ld\n", power(2, 10));
printf("factorial(7) = %ld\n", factorial(7));
printf("reverse_digits(9876) = %d\n", reverse_digits(9876));
return 0;
}
$ gcc main.c mathlib.c -o mathutil && ./mathutil
is_prime(29) = 1
is_prime(30) = 0
gcd(84, 30) = 6
power(2, 10) = 1024
factorial(7) = 5040
reverse_digits(9876) = 6789
- The include guard.
#ifndef / #define / #endif in mathlib.h stops the same declarations being read twice if the header is ever included from more than one place.
- Three files, one program.
mathlib.h declares; mathlib.c defines; main.c only ever #includes the header, never the .c file.
💡This is the shape every real C project uses. gcc main.c mathlib.c -o mathutil compiles both source files and links them together; the compiler needs mathlib.h just to know each function's signature while compiling main.c, and the linker needs mathlib.c's object code to supply the bodies. This exact pattern — a header of declarations, a source file of definitions, and callers that only see the header — is what the capstone project will be built from.
06
Arrays & Strings
- Storing many values under one name — declaration, traversal, and what indexing really means in memory
- Linear and binary search, and three classic sorts: bubble, selection, insertion
- 2D arrays and matrices, then strings — C's
char arrays with a hidden '\0'
- Practical 6 — matrix multiplication, palindrome check and word count in one menu
Day 10 · Module 6 · Arrays & strings
Today we cover
- How an array sits in memory, and why
a[i] is really just an address calculation
- Declaring and traversing an array, and counting its elements with
sizeof
- Linear search versus binary search — and why one needs a sorted array first
- Three ways to sort: bubble, selection and insertion — same result, different mechanics
By the end of today you can traverse and search an array, explain the comparison-count difference between linear and binary search, and trace a bubble, selection or insertion sort pass by hand.
Where the elements actually live
One block, laid out back to back
| Element | Address | Offset from base |
a[0] | 2000 | 0 |
a[1] | 2004 | 4 |
a[2] | 2008 | 8 |
a[3] | 2012 | 12 |
a[4] | 2016 | 16 |
Illustrative addresses for int a[5] with base address 2000 and sizeof(int) == 4 — real addresses depend on the machine and change on every run.
The array is one block. int a[5] reserves 5 × sizeof(int) bytes, contiguous in memory, all at once. a itself names the address of a[0] — the base address.
Indexing is arithmetic. To reach a[i], C computes base address + i × sizeof(int). That is not a metaphor — it is exactly the calculation the compiler generates, which is why a[i] and *(a + i) are two spellings of the identical operation.
💡This is the bridge to Module 7. Right now, a + i is described in words — "the address i elements past the base". Once pointers are introduced, that description becomes literal: a decays to a pointer to its first element, and a + i is real pointer arithmetic that the compiler scales by sizeof(int) automatically. Everything on this slide is exact, not approximate — pointers do not change the model, they just give you the vocabulary to write it directly.
Syntax, initialisation, and counting elements
Declare it, fill it, walk it
#include <stdio.h>
int main(void)
{
int marks[5] = {78, 65, 90, 42, 88}; /* size fixed by the compiler */
int scores[5] = {50, 60}; /* rest are zero-initialised */
int n = sizeof(marks) / sizeof(marks[0]);
int i;
for (i = 0; i < n; i++)
printf("marks[%d] = %d\n", i, marks[i]);
for (i = 0; i < 5; i++)
printf("scores[%d] = %d\n", i, scores[i]);
printf("Number of elements in marks = %d\n", n);
return 0;
}
Output
marks[0] = 78
marks[1] = 65
marks[2] = 90
marks[3] = 42
marks[4] = 88
scores[0] = 50
scores[1] = 60
scores[2] = 0
scores[3] = 0
scores[4] = 0
Number of elements in marks = 5
- Declaration.
int marks[5] reserves five ints. The size in the brackets must be a constant known at compile time for an ordinary array like this.
- Partial initialisation.
int scores[5] = {50, 60} sets the first two elements and zero-fills the rest — C guarantees this, it is not garbage.
- Counting with sizeof.
sizeof(marks) / sizeof(marks[0]) divides the array's total size by one element's size, giving the element count without hard-coding 5 anywhere.
⚠That sizeof trick only works here. It relies on marks still being a real array in the scope where it is used. Pass it into a function and it decays to a plain pointer — sizeof on a pointer gives the pointer's own size, not the array's, a mistake that silently produces the wrong count. Module 7 explains exactly why.
Same question, two different costs
Sorted data lets you skip half
#include <stdio.h>
int linear_search(int a[], int n, int key, int *cmp)
{
int i;
*cmp = 0;
for (i = 0; i < n; i++) {
(*cmp)++;
if (a[i] == key) return i;
}
return -1;
}
int binary_search(int a[], int n, int key, int *cmp)
{
int lo = 0, hi = n - 1;
*cmp = 0;
while (lo <= hi) {
int mid = (lo + hi) / 2;
(*cmp)++;
if (a[mid] == key) return mid;
if (a[mid] < key) lo = mid + 1;
else hi = mid - 1;
}
return -1;
}
int main(void)
{
/* binary search requires a sorted array */
int arr[10] = {3, 8, 12, 19, 24, 31, 40, 55, 67, 78};
int n = 10, key, pos, cmp;
key = 67;
pos = linear_search(arr, n, key, &cmp);
printf("linear : key %d -> index %d, comparisons %d\n", key, pos, cmp);
pos = binary_search(arr, n, key, &cmp);
printf("binary : key %d -> index %d, comparisons %d\n", key, pos, cmp);
key = 3;
pos = linear_search(arr, n, key, &cmp);
printf("linear : key %d -> index %d, comparisons %d\n", key, pos, cmp);
pos = binary_search(arr, n, key, &cmp);
printf("binary : key %d -> index %d, comparisons %d\n", key, pos, cmp);
return 0;
}
Output
linear : key 67 -> index 8, comparisons 9
binary : key 67 -> index 8, comparisons 3
linear : key 3 -> index 0, comparisons 1
binary : key 3 -> index 0, comparisons 3
- Linear search checks every element in order until it finds the key or runs out — it needs no sorting, but a key near the end costs almost
n comparisons.
- Binary search only works on a sorted array. It checks the middle element and, since the array is sorted, immediately discards the half that cannot contain the key.
- Searching for
67 in this 10-element array: linear takes 9 comparisons (it is near the end), binary takes only 3. Searching for 3, right at the start, linear takes just 1 — but binary still takes 3, because it always halves the range no matter where the key sits.
💡O(n) versus O(log n), in words. Linear search's cost grows in direct proportion to the array's size — double the elements, and the worst case roughly doubles too. Binary search's cost grows with how many times the array can be halved before one element is left — for 10 elements that is about 4 halvings, for 10,000 elements it is only about 14. That gap is why binary search is worth the one-time cost of keeping the data sorted.
Watch the array change, pass by pass
Two ways to sort by swapping
#include <stdio.h>
void print_arr(int arr[], int n)
{
int i;
for (i = 0; i < n; i++)
printf("%d ", arr[i]);
printf("\n");
}
void bubble_sort(int arr[], int n)
{
int i, j, t;
for (i = 0; i < n - 1; i++) {
for (j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
t = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = t;
}
}
printf("After pass %d: ", i + 1);
print_arr(arr, n);
}
}
void selection_sort(int arr[], int n)
{
int i, j, min, t;
for (i = 0; i < n - 1; i++) {
min = i;
for (j = i + 1; j < n; j++) {
if (arr[j] < arr[min])
min = j;
}
t = arr[i];
arr[i] = arr[min];
arr[min] = t;
printf("After pass %d: ", i + 1);
print_arr(arr, n);
}
}
Output
Bubble sort
Start: 29 10 14 37 3 22
After pass 1: 10 14 29 3 22 37
After pass 2: 10 14 3 22 29 37
After pass 3: 10 3 14 22 29 37
After pass 4: 3 10 14 22 29 37
After pass 5: 3 10 14 22 29 37
Selection sort
Start: 29 10 14 37 3 22
After pass 1: 3 10 14 37 29 22
After pass 2: 3 10 14 37 29 22
After pass 3: 3 10 14 37 29 22
After pass 4: 3 10 14 22 29 37
After pass 5: 3 10 14 22 29 37
- Bubble sort. Each pass walks the array comparing neighbours and swapping the out-of-order ones, so the largest unsorted value "bubbles" to its final position at the end of every pass.
- Selection sort. Each pass scans the remaining unsorted part for its minimum, then swaps it directly into place — exactly one swap per pass, no matter how unsorted the rest is.
- Both need at most
n - 1 passes, and both sort this six-element array fully by pass 4 — but selection sort still runs pass 5 even though nothing is left to fix, since it has no way to notice early that the array is already sorted.
💡Printing after every pass is the whole point. A sort's final output only ever shows the finished array; watching it change pass by pass is what makes bubble sort's neighbour-swapping and selection sort's find-the-minimum visibly different mechanisms, even though they end at the same sorted array.
Building a sorted section one item at a time
Like sorting cards in your hand
void insertion_sort(int arr[], int n)
{
int i, j, key;
for (i = 1; i < n; i++) {
key = arr[i];
j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
printf("After pass %d: ", i);
print_arr(arr, n);
}
}
Output
Insertion sort
Start: 29 10 14 37 3 22
After pass 1: 10 29 14 37 3 22
After pass 2: 10 14 29 37 3 22
After pass 3: 10 14 29 37 3 22
After pass 4: 3 10 14 29 37 22
After pass 5: 3 10 14 22 29 37
- The sorted section grows by one. Before pass
i begins, arr[0..i-1] is already sorted. The pass takes arr[i] as the key and shifts every larger element in the sorted section one step right to make room for it.
- Pass 3 does nothing visible.
37 is already bigger than everything to its left, so the while loop's condition is false immediately — the array does not change, but the sorted section has still grown to include it.
- This is the same motion as sorting playing cards by hand: pick up the next card, and slide it left past every card bigger than it.
💡Insertion sort is fast on nearly-sorted data. If the array were already sorted, every while condition would fail on its first check, and the whole sort would cost only n - 1 comparisons total — far cheaper than bubble or selection sort's fixed number of passes. That is why it is the sort real libraries fall back to for small or almost-sorted inputs.
Day 11 · Module 6 · Arrays & strings
Today we cover
- 2D arrays and matrices — rows, columns, and nested traversal
- Strings as
char arrays, and the hidden '\0' that makes them work
string.h, and writing your own strlen, strcpy, strcmp and strrev
- Arrays of strings, and Practical 6 — matrix multiplication, palindrome check and word count in one menu
By the end of today you can work with a 2D array, explain exactly why a string needs its terminator, and write a small library of your own string functions.
An array of arrays — rows and columns
Two indices, one block of memory
#include <stdio.h>
#define N 3
void print_matrix(int m[N][N])
{
int i, j;
for (i = 0; i < N; i++) {
for (j = 0; j < N; j++)
printf("%4d", m[i][j]);
printf("\n");
}
}
void matrix_add(int a[N][N], int b[N][N], int sum[N][N])
{
int i, j;
for (i = 0; i < N; i++)
for (j = 0; j < N; j++)
sum[i][j] = a[i][j] + b[i][j];
}
void matrix_multiply(int a[N][N], int b[N][N], int prod[N][N])
{
int i, j, k;
for (i = 0; i < N; i++) {
for (j = 0; j < N; j++) {
prod[i][j] = 0;
for (k = 0; k < N; k++) prod[i][j] += a[i][k] * b[k][j];
}
}
}
int main(void)
{
int a[N][N] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
int b[N][N] = {{9, 8, 7}, {6, 5, 4}, {3, 2, 1}};
int sum[N][N], prod[N][N];
matrix_add(a, b, sum);
matrix_multiply(a, b, prod);
printf("Matrix A\n"); print_matrix(a);
printf("Matrix B\n"); print_matrix(b);
printf("A + B\n"); print_matrix(sum);
printf("A x B\n"); print_matrix(prod);
return 0;
}
Output
Matrix A
1 2 3
4 5 6
7 8 9
Matrix B
9 8 7
6 5 4
3 2 1
A + B
10 10 10
10 10 10
10 10 10
A x B
30 24 18
84 69 54
138 114 90
- Declaration.
int a[N][N] is an array of N arrays, each holding N ints — still one contiguous block, stored row by row.
- Addition pairs up elements at the same
[i][j] position — one nested loop, one add per cell.
- Multiplication is different:
prod[i][j] is the sum of row i of a multiplied element-by-element against column j of b — a third, innermost loop that addition never needed.
💡Three nested loops, three jobs. The outer two loops in matrix_multiply pick which cell of prod is being filled; the innermost loop is the actual dot product for that one cell. Forgetting to reset prod[i][j] = 0 before that innermost loop is the single most common matrix-multiplication bug — without it, every cell keeps adding onto whatever garbage was already there.
C has no separate string type
A string is just a char array
#include <stdio.h>
int main(void)
{
/* three ways to declare a string */
char s1[] = "Hello";
char s2[20] = "Hello";
char s3[] = {'H', 'e', 'l', 'l', 'o', '\0'};
printf("s1 = %s\n", s1);
printf("s2 = %s\n", s2);
printf("s3 = %s\n", s3);
return 0;
}
Output
s1 = Hello
s2 = Hello
s3 = Hello
char s1[] = "Hello"; lets the compiler count the letters and size the array itself — the most common style.
char s2[20] = "Hello"; reserves a fixed 20-byte buffer, useful when the string needs room to grow later, for example with strcat.
char s3[] = {'H','e','l','l','o','\0'}; builds the same string one character at a time — and makes visible something the other two forms hide.
💡All three are the identical five-letter string in memory. "Hello" as a string literal is really six characters, not five — the compiler silently appends a '\0' after the letters you typed, which is exactly why s3's explicit array had to include it by hand. Every C function that takes a string, including printf's %s, relies on that '\0' being there to know where the string ends.
The one byte that makes a char array a string
sizeof counts bytes, strlen counts letters
#include <string.h>
#include <stdio.h>
int main(void)
{
char s1[] = "Hello";
char s2[20] = "Hello";
printf("sizeof(s1) = %zu\n", sizeof(s1));
printf("strlen(s1) = %zu\n", strlen(s1));
printf("sizeof(s2) = %zu\n", sizeof(s2));
printf("strlen(s2) = %zu\n", strlen(s2));
return 0;
}
Output
sizeof(s1) = 6
strlen(s1) = 5
sizeof(s2) = 20
strlen(s2) = 5
sizeof(s1) == 6. Five letters plus the compiler-added '\0' — sizeof reports the array's full reserved size, decided at compile time.
strlen(s1) == 5. strlen walks the array counting bytes until it hits the first '\0', then stops — it never counts the terminator itself.
s2's gap. sizeof(s2) == 20 but strlen(s2) == 5 — the extra 15 bytes are reserved but unused, exactly the room s2 was given to grow into later.
⚠What happens if the terminator is missing. char bad[5] = {'H','e','l','l','o'}; is legal C — it fills the array exactly full, with no room left for a '\0'. Passing bad to strlen or printf("%s", bad) is undefined behaviour: both keep reading byte after byte past the end of the array, looking for a zero byte that was never placed there, until they happen to hit one somewhere in whatever memory follows. The output is not wrong in a predictable way — it is not reproducible at all, and on a bad day it reads memory the program was never meant to touch. This is not a demonstration to run; it is the reason every hand-built string must end in '\0'.
The standard library's own string toolbox
Don't write what's already written — and tested
| Function | Does |
strlen(s) | Returns the number of characters before the '\0' |
strcpy(dest, src) | Copies src, terminator included, into dest |
strcat(dest, src) | Appends src onto the end of dest, after its existing '\0' |
strcmp(a, b) | Compares two strings; returns 0 if equal, negative if a sorts first, positive otherwise |
#include <stdio.h>
#include <string.h>
int main(void)
{
char first[20] = "Nandan";
char full[40];
printf("strlen(first) = %zu\n", strlen(first));
strcpy(full, first);
strcat(full, " Kumar");
printf("after strcpy + strcat: %s\n", full);
printf("strcmp(\"cat\", \"cat\") = %d\n", strcmp("cat", "cat"));
printf("strcmp(\"apple\", \"banana\") = %d\n", strcmp("apple", "banana"));
if (strcmp(full, "Nandan Kumar") == 0)
printf("full matches \"Nandan Kumar\"\n");
return 0;
}
Output
strlen(first) = 6
after strcpy + strcat: Nandan Kumar
strcmp("cat", "cat") = 0
strcmp("apple", "banana") = -1
full matches "Nandan Kumar"
full must be big enough. strcpy and strcat trust the caller completely — neither checks that dest has room. full[40] is sized generously on purpose.
strcmp's return value is a sign, not a distance. A negative value means "less than" — here, -1 for this particular call — not "one character different"; only == 0 should ever be trusted as an exact meaning.
⚠Why strcmp(a, b) == 0, never a == b. a == b compares two addresses, not the text they point to — two different arrays holding the identical word are still different addresses, so == would wrongly say "not equal". strcmp is what actually compares the characters. This exact mistake — comparing strings with == — is one of the most common bugs a student meeting C after Python or Java writes.
What the library function is really doing
Four functions, walk the '\0' yourself
#include <stdio.h>
#include <string.h>
int my_strlen(const char *s)
{
int len = 0;
while (s[len] != '\0')
len++;
return len;
}
void my_strcpy(char *dest, const char *src)
{
int i = 0;
while (src[i] != '\0') {
dest[i] = src[i];
i++;
}
dest[i] = '\0';
}
int my_strcmp(const char *a, const char *b)
{
int i = 0;
while (a[i] != '\0' && a[i] == b[i])
i++;
return (unsigned char) a[i] - (unsigned char) b[i];
}
my_strlen counts up from index 0 until it meets '\0' — there is nothing cleverer inside the real strlen either.
my_strcpy copies characters one at a time, then adds the terminator after the loop ends — forgetting that last line is the most common bug in a hand-written copy.
my_strcmp walks both strings together, stopping at the first mismatch or the first terminator; the byte difference at that point is the return value's sign.
void my_strrev(char *s)
{
int i = 0, j = my_strlen(s) - 1;
while (i < j) {
char t = s[i];
s[i] = s[j];
s[j] = t;
i++;
j--;
}
}
int main(void)
{
char src[] = "BrainBit Infotech", dest[20];
printf("my_strlen = %d, strlen = %zu\n", my_strlen(src), strlen(src));
my_strcpy(dest, src);
printf("my_strcpy -> %s\n", dest);
printf("my_strcmp(cat,dog) = %d, strcmp = %d\n",
my_strcmp("cat", "dog"), strcmp("cat", "dog"));
my_strrev(dest);
printf("my_strrev -> %s\n", dest);
return 0;
}
Output
my_strlen = 17, strlen = 17
my_strcpy -> BrainBit Infotech
my_strcmp(cat,dog) = -1, strcmp = -1
my_strrev -> hcetofnI tiBniarB
my_strrev swaps from both ends inward, using my_strlen itself to find the last index — a small proof that these hand-written functions can lean on each other.
- Every
my_* result on the right matches its string.h counterpart exactly — that agreement is the actual proof these implementations are correct, not just plausible-looking.
💡Why write these at all, when string.h already exists? Writing your own strlen, strcpy and strcmp is one of the most common exam and interview exercises in C, precisely because it forces the '\0'-walking mental model that every other string operation in this course depends on. In real code, always reach for string.h — it is tested far more thoroughly than a version written once for an exam.
A 2D char array, read as a list of words
Rows of text, not rows of numbers
#include <stdio.h>
#include <string.h>
int main(void)
{
char days[7][10] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
int i;
for (i = 0; i < 7; i++)
printf("days[%d] = %s\n", i, days[i]);
printf("sizeof(days) = %zu, sizeof(days[0]) = %zu\n",
sizeof(days), sizeof(days[0]));
for (i = 0; i < 7; i++) {
if (strcmp(days[i], "Wed") == 0)
printf("Found \"Wed\" at index %d\n", i);
}
return 0;
}
Output
days[0] = Sun
days[1] = Mon
days[2] = Tue
days[3] = Wed
days[4] = Thu
days[5] = Fri
days[6] = Sat
sizeof(days) = 70, sizeof(days[0]) = 10
Found "Wed" at index 3
char days[7][10] is a 2D char array — seven rows, each a 10-byte buffer holding one short string, the same shape as the matrices earlier today.
days[i] names row i on its own, which decays to a plain char * — that is why it can be handed straight to printf's %s or to strcmp.
- Fixed-width rows waste space.
"Sun" only needs 4 bytes, but every row reserves 10 — sizeof(days) == 70 confirms all 7 × 10 bytes are reserved regardless of how short each word actually is.
💡Searching an array of strings needs strcmp, not ==. days[i] == "Wed" would compare two addresses and almost always be false, even on a genuine match — the loop here correctly uses strcmp(days[i], "Wed") == 0 instead, the same rule from the string.h slide, now applied inside a loop.
Module 6 practical
One menu, three programs
#include <stdio.h>
#include <string.h>
#define N 2
void read_matrix(int m[N][N])
{
int i, j;
for (i = 0; i < N; i++)
for (j = 0; j < N; j++)
scanf("%d", &m[i][j]);
}
void multiply_matrices(int a[N][N], int b[N][N], int prod[N][N])
{
int i, j, k;
for (i = 0; i < N; i++) {
for (j = 0; j < N; j++) {
prod[i][j] = 0;
for (k = 0; k < N; k++) prod[i][j] += a[i][k] * b[k][j];
}
}
}
- Option 1 — matrix multiply.
read_matrix and multiply_matrices are the same logic from this morning's matrix slide, just given their own names.
- Splitting a big task into small, named helpers is exactly the Module 5 lesson — a practical this size is where that habit starts paying off.
void print_matrix(int m[N][N])
{
int i, j;
for (i = 0; i < N; i++) {
for (j = 0; j < N; j++) printf("%4d", m[i][j]);
printf("\n");
}
}
void matrix_menu(void)
{
int a[N][N], b[N][N], prod[N][N];
printf("Enter %dx%d matrix A:\n", N, N); read_matrix(a);
printf("Enter %dx%d matrix B:\n", N, N); read_matrix(b);
multiply_matrices(a, b, prod);
printf("A x B =\n");
print_matrix(prod);
}
void palindrome_check(void)
{
char s[100];
int i, j, is_pal = 1;
printf("Enter a word: ");
scanf("%99s", s);
for (i = 0, j = strlen(s) - 1; i < j; i++, j--) {
if (s[i] != s[j]) {
is_pal = 0;
break;
}
}
printf("\"%s\" is %sa palindrome\n", s, is_pal ? "" : "not ");
}
matrix_menu only prints the prompts and calls the three helpers — it reads as a short summary of what option 1 does.
- Option 2 — palindrome check. Two indices walk inward from both ends at once; the moment any pair of characters disagrees,
is_pal is set to 0 and break stops the loop immediately — there is no point checking the rest.
void word_count(void)
{
char line[200];
int i, count = 0, in_word = 0;
printf("Enter a line of text: ");
scanf(" %199[^\n]", line);
for (i = 0; line[i] != '\0'; i++) {
if (line[i] == ' ') {
in_word = 0;
} else if (!in_word) {
in_word = 1;
count++;
}
}
printf("Word count = %d\n", count);
}
int main(void)
{
int choice;
do {
printf("\n1. Matrix multiply 2. Palindrome check 3. Word count 4. Exit\n");
printf("Enter choice: ");
scanf("%d", &choice);
switch (choice) {
case 1: matrix_menu(); break;
case 2: palindrome_check(); break;
case 3: word_count(); break;
case 4: printf("Exiting...\n"); break;
default: printf("Invalid choice\n");
}
} while (choice != 4);
return 0;
}
Output
1. Matrix multiply 2. Palindrome check 3. Word count 4. Exit
Enter choice: 1
Enter 2x2 matrix A:
1 2
3 4
Enter 2x2 matrix B:
5 6
7 8
A x B =
19 22
43 50
1. Matrix multiply 2. Palindrome check 3. Word count 4. Exit
Enter choice: 2
Enter a word: madam
"madam" is a palindrome
1. Matrix multiply 2. Palindrome check 3. Word count 4. Exit
Enter choice: 3
Enter a line of text: the quick brown fox
Word count = 4
1. Matrix multiply 2. Palindrome check 3. Word count 4. Exit
Enter choice: 4
Exiting...
- Option 3 — word count.
in_word is a flag, not a counter — count only increases on the character where a run of non-spaces begins, so several letters in a row still count as one word.
do-while and switch together make the classic menu shape from Practical 4 — the menu always shows at least once, and each option is one case.
💡One file, three unrelated problems, one consistent shape. Matrix multiplication, a palindrome check and a word count share no logic at all — what they share is the discipline this whole module has been building: declare the right array or string, traverse it correctly, and hand the real work to a small, named function. That is the actual skill Days 10 and 11 were teaching.
07
Pointers
- What a pointer actually is: a variable that stores an address, nothing more
& to take an address, * to follow one back to its value, and the arithmetic in between
- The Day 8
swap that failed — fixed for good, and why "call by reference" in C is really call by value of an address
- Strings, arrays of pointers, pointers to pointers, and pointers to functions
Day 12 · Module 7 · Pointers
Today we cover
- What a pointer is, and how
& and * are opposites of each other
- Pointer arithmetic, and why the address jumps by
sizeof the pointed-to type, not by one byte
- The real relationship between pointers and arrays — and why an array is not a pointer
- Fixing Day 8's
swap for good, and the three ways a pointer can go wrong
By the end of today you can declare and dereference a pointer, explain why a[i] and *(a + i) are the same operation, and write a function that actually changes its caller's variables.
A new kind of variable
A variable that stores an address
| Ordinary variable | Pointer variable |
int num = 42; | int *p = # |
Holds a value — 42 | Holds an address — where num lives |
| Lives at some address too | Points at that address |
Every variable in a running program occupies memory and therefore has an address, whether or not anything ever looks at it.
The postal analogy. A house has an address. A variable is the house; its address is a number like 2000. A pointer is a slip of paper with that number written on it — not the house itself, just directions to it.
Declaration syntax. int *p; declares p as "a pointer to int". The * here is part of the type, not an operator yet — it tells C what kind of thing p points at, which it needs to know before doing arithmetic on p or reading the right number of bytes through it.
💡A pointer is always typed. int *, char * and double * are different types. The address they store might look the same, but the type tells C how many bytes to read or write through it, and how far to move for "the next one" — the subject of the next two slides.
Two operators, opposite directions
Take an address, then follow it back
#include <stdio.h>
int main(void)
{
int num = 42;
int *p = #
printf("num = %d\n", num);
printf("&num = %p\n", (void *)&num);
printf("p = %p\n", (void *)p);
printf("*p = %d\n", *p);
*p = 100;
printf("after *p = 100, num = %d\n", num);
return 0;
}
Output
num = 42
&num = 0000006DE0FFF834
p = 0000006DE0FFF834
*p = 42
after *p = 100, num = 100
& — address-of. &num is not a value of num's type — it is the address where num lives, and that address is exactly what gets stored into p.
* — dereference. *p means "go to the address p holds, and read (or write) the int there". *p = 100; changes num itself, through p.
p and &num print identically — the same address, because p was set to point at num.
💡Your addresses will differ. 0000006DE0FFF834 is where this program happened to put num on this run. Run it again, or on another machine, and the digits change — the operating system places a program's stack at a different spot almost every time. What must never change is that p's line matches &num's line, and that *p equals num.
+1 does not mean "add one byte"
The step is sizeof the type
#include <stdio.h>
void walk_ints(int nums[])
{
int *ip;
printf("int array, stride = sizeof(int) = %zu\n", sizeof(int));
for (ip = nums; ip < nums + 4; ip++)
printf(" %p -> %d\n", (void *)ip, *ip);
}
void walk_chars(char chars[])
{
char *cp;
printf("char array, stride = sizeof(char) = %zu\n", sizeof(char));
for (cp = chars; cp < chars + 4; cp++)
printf(" %p -> %c\n", (void *)cp, *cp);
}
int main(void)
{
int nums[4] = {10, 20, 30, 40};
char chars[4] = {'A', 'B', 'C', 'D'};
walk_ints(nums);
walk_chars(chars);
return 0;
}
Output
int array, stride = sizeof(int) = 4
0000007578BFFB40 -> 10
0000007578BFFB44 -> 20
0000007578BFFB48 -> 30
0000007578BFFB4C -> 40
char array, stride = sizeof(char) = 1
0000007578BFFB3C -> A
0000007578BFFB3D -> B
0000007578BFFB3E -> C
0000007578BFFB3F -> D
ip++ jumps by 4. ip is an int *. B40 → B44 → B48 → B4C — each step is 4 bytes, exactly sizeof(int), so ip always lands on the next whole int.
cp++ jumps by 1. cp is a char *. B3C → B3D → B3E → B3F — one byte at a time, because sizeof(char) is 1.
- Same
+1 in the source, two completely different byte jumps — the compiler scales the step by the pointer's own type.
💡This is why pointers need a type at all. p + 1 never means "one byte later" — it means "one element later", whatever size that element is. Get the type wrong (say, a char * walking what is really an int array) and every step lands one byte short of where it should. As on the previous slide, the exact digits are only for this run — run it again and every address shifts, but the 4-byte and 1-byte strides between consecutive addresses never do.
The most misunderstood rule in C
An array is not a pointer
#include <stdio.h>
void show_size(int *a)
{
printf("inside show_size, sizeof(a) = %zu\n", sizeof(a));
}
int main(void)
{
int a[5] = {1, 2, 3, 4, 5};
printf("inside main, sizeof(a) = %zu\n", sizeof(a));
show_size(a);
return 0;
}
Output
inside main, sizeof(a) = 20
inside show_size, sizeof(a) = 8
- Inside
main, a is the real array. sizeof(a) is 20 — 5 × sizeof(int), the whole block. a here genuinely names 5 integers.
- Passed to a function, it decays. Inside
show_size, sizeof(a) is 8 — the size of one pointer on this toolchain. Only the base address survived the trip; the "how many" information did not.
- Writing the parameter as
int a[] instead of int *a changes nothing — C treats them as identical. In a parameter list, [] is only ever a pointer in disguise.
💡Closing the loop from Day 10. The Array memory model slide described a[i] as an address calculation: base address + i × sizeof(int). That description is now literal — a decays to a pointer to its first element, a + i is real pointer arithmetic, and a[i] == *(a + i) exactly, always. Decay is what makes the two spellings identical — but decay only happens when an array is used as a pointer (passed to a function, assigned, compared); sizeof applied right where the array was declared still sees the whole block. That is exactly why this slide's own two numbers differ.
Fixing Day 8's swap, for good
Still call by value — of an address
#include <stdio.h>
void swap(int *a, int *b)
{
int t = *a;
*a = *b;
*b = t;
}
int main(void)
{
int x = 10, y = 20;
printf("before: x = %d, y = %d\n", x, y);
swap(&x, &y);
printf("after: x = %d, y = %d\n", x, y);
return 0;
}
Output
before: x = 10, y = 20
after: x = 20, y = 10
- Same program, fixed. This is the exact
swap from Day 8's Call by value slide (byvalue.c), which printed x = 10, y = 20 — unchanged. Only the parameter types and the call site changed.
main passes addresses. &x and &y are still just values — addresses, copied into a and b exactly as before. C never stopped being call by value.
swap dereferences to reach back. *a and *b follow those copied addresses to the original x and y in main, so this time the swap is real — the output proves it.
💡"Call by reference" is not a separate mechanism in C. Some languages have real reference parameters built in; C does not. What C offers is a pointer, passed by value like everything else, then dereferenced by hand. Learn it this way now, and int ** later in this module will not confuse you — it is just a pointer to a pointer, passed by value, dereferenced twice.
Three ways a pointer can go wrong
Not the same problem — three problems
| Kind | What it holds | How it happens |
| NULL | Address 0, deliberately | int *p = NULL; — "points at nothing", on purpose |
| Wild | Garbage — whatever bytes were already there | int *p; — declared, never given an address |
| Dangling | The address of something that no longer exists | Points at a local variable whose function already returned |
Only NULL is safe to check for. A wild or dangling pointer looks exactly like a valid one — nothing about its value says "do not use this".
NULL is the one you can defend against. Because NULL is a known, fixed value, code can test for it before dereferencing — the whole point of the guard below.
A dangling pointer, concretely. A function declares int local = 5; and returns &local. The moment the function returns, local's memory is no longer reserved for it — the returned address now points at a stack slot the program is free to reuse for the very next call. The pointer's value looks perfectly valid; what it points at is gone.
💡Module 8 is built on this idea. Dynamic memory adds a related danger: a pointer left holding the address of memory that was explicitly freed. Same failure shape as dangling — the pointer looks fine, the memory behind it is gone — just a different reason it became gone. Recognise the shape now.
#include <stdio.h>
#include <stddef.h>
void print_value(int *p)
{
if (p != NULL)
printf("value = %d\n", *p);
else
printf("pointer is NULL, nothing to print\n");
}
int main(void)
{
int num = 7;
int *ok = #
int *empty = NULL;
print_value(ok);
print_value(empty);
return 0;
}
Output
value = 7
pointer is NULL, nothing to print
- The guard.
if (p != NULL) before every dereference is the one habit that defends against a NULL pointer. It costs one comparison and prevents a crash.
- It does not defend against wild or dangling pointers — those hold addresses that are not
NULL, just wrong. The guard only catches the one case the language gives a clean signal for.
Day 13 · Module 7 · Pointers
Today we cover
- Pointers and strings —
char *s versus char s[], and why one must not be modified
- Arrays of pointers, seen through
main's own argv
- Pointer to pointer, and passing a function itself as an argument
- Practical 7 — a bubble sort and a
strcpy rewritten entirely with pointers
By the end of today you can choose correctly between a string-literal pointer and a character array, read char *argv[] on sight, and pass a function as an argument to another function.
Two ways to spell a string, one safe to edit
A pointer to a literal, or a copy of one
#include <stdio.h>
int main(void)
{
char *lit = "hello";
char arr[] = "hello";
printf("lit = %s sizeof(lit) = %zu (a pointer)\n", lit, sizeof(lit));
printf("arr = %s sizeof(arr) = %zu (the array, strlen + 1)\n", arr, sizeof(arr));
arr[0] = 'H';
printf("arr after arr[0] = 'H': %s\n", arr);
return 0;
}
Output
lit = hello sizeof(lit) = 8 (a pointer)
arr = hello sizeof(arr) = 6 (the array, strlen + 1)
arr after arr[0] = 'H': Hello
char *lit = "hello"; lit is a pointer, 8 bytes on this toolchain, holding the address of the string literal "hello" — which C is free to place in read-only memory.
char arr[] = "hello"; arr is a real array, 6 bytes (h e l l o \0) — its own private, writable copy of those characters.
arr[0] = 'H'; is safe — arr owns that memory, and the output proves it changed.
💡Never write lit[0] = 'H';. This program deliberately never attempts it. lit points at a string literal, and the C standard allows the compiler to store literals in read-only memory. Writing to one is undefined behaviour — not "always crashes", not "always fine". Depending on the platform it may crash immediately, silently corrupt something else, or appear to work today and fail somewhere else. The only correct rule: if a string needs to change, put it in an array — never leave it as a raw literal pointer.
main's own parameters
argv is exactly that
#include <stdio.h>
int main(int argc, char *argv[])
{
int i;
printf("argc = %d\n", argc);
for (i = 0; i < argc; i++)
printf("argv[%d] = %s\n", i, argv[i]);
return 0;
}
$ ./argv Nandan 21 CSE
argc = 4
argv[0] = C:\cdemo\argv.exe
argv[1] = Nandan
argv[2] = 21
argv[3] = CSE
char *argv[] is an array of pointers to char — each element is a separate string, and the strings need not be the same length or laid out together.
argc counts how many of those pointers are valid: the program's own invocation plus every word typed after it on the command line.
- Different from Module 6's array of strings (
char names[][20]), where every row is the same fixed width in one contiguous block. Here each argv[i] can point anywhere, to a string of any length.
💡You have been using this since Day 1. int main(void) is shorthand for "I do not need the command-line arguments". The full signature, int main(int argc, char *argv[]), was always available — and argv[0]'s exact form is implementation-defined: here it is a full path, elsewhere it might just be ./argv. Only the count and the words typed are guaranteed.
One more layer of indirection
A pointer that points at a pointer
#include <stdio.h>
int main(void)
{
int num = 5;
int *p = #
int **pp = &p;
printf("num = %d\n", num);
printf("*p = %d\n", *p);
printf("**pp = %d\n", **pp);
**pp = 50;
printf("after **pp = 50, num = %d\n", num);
return 0;
}
Output
num = 5
*p = 5
**pp = 5
after **pp = 50, num = 50
int **pp = &p; pp stores the address of p, not of num. pp is a pointer to int *.
- One
* at a time. *pp follows pp to reach p itself — a pointer. **pp follows once more, from p to num.
**pp = 50; reaches all the way back to num, exactly as *p = ... would — it just took two hops instead of one.
💡This is Call by reference, one layer deeper. If a function needs to change a variable, it takes a pointer to it. If a function needs to change a pointer that its caller holds — make it point somewhere new — it needs a pointer to that pointer. int ** is not a new rule; it is the old rule applied twice.
Passing behaviour, not just data
A function you can hand to another function
#include <stdio.h>
int ascending(int a, int b) { return a > b; }
int descending(int a, int b) { return a < b; }
void sort_ints(int arr[], int n, int (*cmp)(int, int))
{
int i, j, t;
for (i = 0; i < n - 1; i++)
for (j = 0; j < n - i - 1; j++)
if (cmp(arr[j], arr[j + 1])) {
t = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = t;
}
}
void print_arr(int arr[], int n)
{
int i;
for (i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}
int main(void)
{
int marks[5] = {68, 92, 45, 77, 83};
sort_ints(marks, 5, ascending);
printf("ascending: ");
print_arr(marks, 5);
sort_ints(marks, 5, descending);
printf("descending: ");
print_arr(marks, 5);
return 0;
}
Output
ascending: 45 68 77 83 92
descending: 92 83 77 68 45
int (*cmp)(int, int) declares cmp as a pointer to a function taking two ints and returning int. The parentheses around *cmp matter — without them it would declare a function returning a pointer, not a pointer to a function.
sort_ints does not know or care which comparison it is doing — it calls cmp(arr[j], arr[j+1]) and swaps if that call is true. Pass ascending or descending and the same sort produces the opposite order.
💡This is exactly how the standard library's qsort works. qsort sorts any array of any type because it is handed a comparison function pointer — it never needs to know what "ascending" means for your data, only that your function can compare two elements of it.
Module 7 practical
A sort and a copy, rebuilt on pointers
#include <stdio.h>
#define N 6
void swap(int *a, int *b)
{
int t = *a;
*a = *b;
*b = t;
}
void bubble_sort(int arr[], int n)
{
int i, j;
for (i = 0; i < n - 1; i++)
for (j = 0; j < n - i - 1; j++)
if (arr[j] > arr[j + 1])
swap(&arr[j], &arr[j + 1]);
}
void print_arr(int arr[], int n)
{
int i;
for (i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}
void my_strcpy(char *dest, const char *src)
{
while (*src != '\0') {
*dest = *src;
dest++;
src++;
}
*dest = '\0';
}
bubble_sort's swap takes pointers. The same swap(int *a, int *b) from Call by reference — called here as swap(&arr[j], &arr[j + 1]), addresses of two array elements.
my_strcpy walks with pointers, not indices. No [i] anywhere — dest++ and src++ advance both pointers one char at a time until *src is the terminator, then the terminator itself is copied.
void sort_demo(void)
{
int marks[N] = {68, 92, 45, 77, 83, 59};
printf("before: ");
print_arr(marks, N);
bubble_sort(marks, N);
printf("after: ");
print_arr(marks, N);
}
void strcpy_demo(void)
{
char src[50], dest[50];
printf("Enter a string: ");
scanf(" %49[^\n]", src);
my_strcpy(dest, src);
printf("copied: %s\n", dest);
}
int main(void)
{
int choice;
do {
printf("\n1. Bubble sort (pointer swap) 2. my_strcpy 3. Exit\n");
printf("Enter choice: ");
scanf("%d", &choice);
switch (choice) {
case 1: sort_demo(); break;
case 2: strcpy_demo(); break;
case 3: printf("Exiting...\n"); break;
default: printf("Invalid choice\n");
}
} while (choice != 3);
return 0;
}
$ ./practical7
1. Bubble sort (pointer swap) 2. my_strcpy 3. Exit
Enter choice: 1
before: 68 92 45 77 83 59
after: 45 59 68 77 83 92
1. Bubble sort (pointer swap) 2. my_strcpy 3. Exit
Enter choice: 2
Enter a string: Hello World
copied: Hello World
1. Bubble sort (pointer swap) 2. my_strcpy 3. Exit
Enter choice: 3
Exiting...
- Two completely different jobs — sorting numbers, copying text — and both now move through memory the same way: an address, a step, a check for "am I done", repeated.
💡This is the whole module, in one file. swap reaches back through a pointer to change its caller's data — Call by reference. my_strcpy treats a string as what it always was — a run of memory walked one address at a time, not a row of numbered boxes. Indexing with [i] was never a different thing from pointer arithmetic; it was always this, spelled more comfortably.
08
Dynamic memory
- Why
int marks[50]; is a guess, and what to write when the size is only known while the program runs
- The stack and the heap — two kinds of memory, two lifetimes, two sets of rules
malloc, calloc, realloc, free — and why every one of them is a pointer problem first
- Leaks, double frees and dangling pointers: the three ways this goes wrong, and the habits that prevent each
Day 14 · Module 8 · Dynamic memory
Today we cover
- Where your variables have been living all along — the stack — and the other place, the heap
- Asking for memory at run time with
malloc and calloc, and giving it back with free
- Resizing an allocation with
realloc, and the one line of it everybody gets wrong
- Memory leaks, double frees, dangling pointers — what each looks like and how to not write it
- Building a 2D array whose rows and columns are both decided at run time
By the end of today you can write a program that asks the user how much data there is, allocates exactly that much, uses it, and frees it — with no fixed-size array anywhere in the file.
Two kinds of memory
The size has to be a guess — unless you ask at run time
The problem. int marks[50]; commits you before the program has met a single student. 30 students wastes 20 slots; 60 students does not fit. The size is fixed when the program is compiled, but it is only known when the program is run.
| Stack | Heap |
| Ordinary local variables and arrays | Whatever you ask malloc for |
| Size fixed at compile time | Size decided at run time |
| Freed automatically when the function returns | Freed only when you call free |
| Small — typically 1–8 MB for the whole program | Large — limited by the machine, not the language |
| Fast: the stack pointer just moves | Slower: the allocator has to find a gap |
| Dies with its frame | Lives until freed — can outlive the function that made it |
Day 8 put the call stack on a slide. That is the same stack. Each call pushes a frame holding that call's locals; each return pops it. This is exactly why returning a pointer to a local array is broken — the frame it lived in is gone the moment you return.
The heap is the answer to that. Memory taken from the heap belongs to no frame. A function can allocate it, return the pointer, and the caller can keep using it long after that function has returned — which is precisely what a function like strdup, or any "build me a thing" function, does.
💡The trade you are making. The heap buys you a size decided at run time and a lifetime you control. It charges you for it: you are now responsible for handing every block back. The stack never sends a bill because it never gave you the choice.
The four functions
Ask, use, give back
#include <stdio.h>
#include <stdlib.h>
int n, i;
printf("How many numbers? ");
scanf("%d", &n);
int *a = malloc(n * sizeof(int));
if (a == NULL) {
printf("Out of memory\n");
return 1;
}
dyn.c · use and give back
for (i = 0; i < n; i++)
a[i] = (i + 1) * (i + 1);
for (i = 0; i < n; i++)
printf("%d ", a[i]);
printf("\n");
free(a); /* hand the memory back */
a = NULL; /* and stop pointing at it */
$ ./dyn
How many numbers? 6
1 4 9 16 25 36
| Call | What it does |
malloc(n * sizeof(int)) | One block of n ints, contents unknown |
calloc(n, sizeof(int)) | Same block, every byte set to zero |
realloc(p, newsize) | Grows or shrinks an existing block, copying what fits |
free(p) | Returns the block. Does not change p |
- All four live in
<stdlib.h>.
malloc takes a count in bytes, not elements — hence n * sizeof(int), never plain n.
- It returns
void *, which assigns to any pointer type. In C you do not cast it; in C++ you must.
- Writing
sizeof(*a) instead of sizeof(int) means the line stays correct if a's type ever changes.
💡a is used exactly like an array — a[i] works, the loop is the loop you already write. That is not a coincidence: Module 7 showed that a[i] is *(a + i). The only new thing here is where the memory came from and who has to hand it back.
czero.c · malloc vs calloc
int *m = malloc(5 * sizeof(int));
int *c = calloc(5, sizeof(int));
printf("malloc: ");
for (i = 0; i < 5; i++) printf("%d ", m[i]);
printf("\ncalloc: ");
for (i = 0; i < 5; i++) printf("%d ", c[i]);
printf("\n");
free(m);
free(c);
$ ./czero
malloc: 7471203 7536751 6684783 6029428 6881367
calloc: 0 0 0 0 0
The malloc row is whatever was left in that memory — it will differ on your machine and between runs. Never read an allocation before you have written to it. calloc zeroes every byte for you, which costs a little time and removes a whole class of bug.
grow.c · realloc, done right
if (count == cap) {
cap = cap * 2;
int *bigger = realloc(a, cap * sizeof(int));
if (bigger == NULL) {
free(a); /* a is still valid */
printf("Out of memory\n");
return 1;
}
a = bigger; /* only now overwrite a */
printf(" [grown to %d slots]\n", cap);
}
a[count] = value;
count++;
$ ./grow
Enter numbers, 0 to stop
10 20 30 40 50 0
[grown to 4 slots]
[grown to 8 slots]
Stored 5: 10 20 30 40 50
Never write a = realloc(a, ...). If it fails it returns NULL, you have just overwritten the only pointer to the old block, and the data is both lost and unfreeable. Catch the result in a second pointer, check it, and assign only on success. realloc may move the block, so the old pointer must not be used again afterwards.
The guard that is never optional
Every allocation can fail
/* an absurd request, so malloc really does fail */
long long huge = 1000000000LL * 1000LL;
int *a = malloc((size_t)huge * sizeof(int));
if (a == NULL) {
printf("malloc failed - nothing was allocated\n");
printf("a = %p\n", (void *)a);
return 1;
}
printf("allocated\n");
free(a);
$ ./guard
malloc failed - nothing was allocated
a = 0000000000000000
On failure malloc returns NULL. It does not stop the program, print anything, or raise an error. It hands back a null pointer and carries on — so the very next a[0] = 1; writes through a null pointer and the program crashes several lines away from the real cause.
The shape to memorise. Allocate, check, then use — never allocate and use.
int *a = malloc(n * sizeof(int));
if (a == NULL) {
/* report and get out */
return 1;
}
/* only now is a safe to use */
💡Why it fails in real programs, not just contrived ones. A negative or garbage n read from the user converts to an enormous size_t; a long-running program that leaks eventually exhausts the heap; a small embedded device has very little heap to begin with. The check costs three lines and turns an unexplainable crash into a message.
Three ways this goes wrong
The same program, leaking and fixed
void report(int n)
{
int *a = malloc(n * sizeof(int));
if (a == NULL) return;
for (int i = 0; i < n; i++) a[i] = i;
printf("%d ", a[n - 1]);
/* no free - n*4 bytes lost, every call */
}
void report(int n)
{
int *a = malloc(n * sizeof(int));
if (a == NULL) return;
for (int i = 0; i < n; i++) a[i] = i;
printf("%d ", a[n - 1]);
free(a); /* the owner releases it */
a = NULL; /* and stops pointing at it */
}
$ ./leak · $ ./noleak
999 999 999 999 999
999 999 999 999 999
Both print the same thing. That is the whole difficulty with memory bugs — the broken one is not slower, not wrong, and not louder. It simply loses 4,000 bytes per call, and only a program that runs for hours, or a tool that counts, ever notices.
| Mistake | What you wrote | What happens |
| Memory leak | Allocated, never freed | The block is never reusable. A loop or long-running service eventually exhausts the heap |
| Double free | free(p); free(p); | Corrupts the allocator's bookkeeping. Usually an abort, sometimes a silent, exploitable mess |
| Dangling pointer | Used p after free(p) | Reads or writes memory that now belongs to something else — undefined behaviour |
| Losing the pointer | p = malloc(..); p = malloc(..); | The first block's address is gone, so it can never be freed |
💡Three habits cover all four rows. (1) Every malloc gets a matching free — write the free before you write the code in between. (2) Set the pointer to NULL right after freeing: a dangling pointer becomes a null pointer, a double free becomes harmless, because free(NULL) is explicitly defined to do nothing. (3) Decide who owns each block — the code that allocates it is the code that frees it. When you need a machine to check, run the program under valgrind on Linux, or rebuild it with gcc -fsanitize=address -g and let the compiler's own sanitizer report the leak with the line number.
Pointers to pointers, put to work
A grid whose rows and columns are both decided at run time
int rows = 3, cols = 4, i, j;
/* an array of ROW POINTERS ... */
int **grid = malloc(rows * sizeof(int *));
if (grid == NULL) return 1;
/* ... then a row behind each one */
for (i = 0; i < rows; i++) {
grid[i] = malloc(cols * sizeof(int));
if (grid[i] == NULL) return 1;
}
dyn2d.c · using and freeing it
for (i = 0; i < rows; i++)
for (j = 0; j < cols; j++)
grid[i][j] = (i + 1) * (j + 1);
/* free the rows FIRST, then the array */
for (i = 0; i < rows; i++)
free(grid[i]);
free(grid);
$ ./dyn2d
1 2 3 4
2 4 6 8
3 6 9 12
int **grid is Module 7's pointer-to-pointer, with a reason to exist. grid points at a block of rows pointers; each of those points at its own block of cols ints. grid[i][j] therefore means "follow grid to row i, follow that to column j" — two hops, not one.
Free in the reverse order of allocation. Free grid first and you have thrown away the only copies of the row addresses — every row is leaked and unreachable. Rows first, then the array of pointers. Allocation goes outside-in; freeing goes inside-out.
| int m[3][4]; | int **grid |
| Size | Fixed when compiled | Read at run time |
| Layout | One contiguous block | rows+1 separate blocks |
| Rows | All the same length | Can differ (a "jagged" array) |
| Cleanup | Automatic | rows+1 calls to free |
💡This is how char *argv[] is built. The shell allocates one array of pointers, then one string behind each. It is also the shape of an array of names, a list of lines read from a file, and — in Module 11 — the linked structures you will build next.
Module 8 practical
A marks report with no fixed-size array in the file
#include <stdio.h>
#include <stdlib.h>
/* Fill the array from the keyboard. */
void read_marks(float *marks, int n)
{
for (int i = 0; i < n; i++) {
printf("Marks of student %d: ", i + 1);
scanf("%f", &marks[i]);
}
}
float total_of(const float *marks, int n)
{
float sum = 0.0f;
for (int i = 0; i < n; i++)
sum += marks[i];
return sum;
}
int index_of_highest(const float *marks, int n)
{
int best = 0;
for (int i = 1; i < n; i++)
if (marks[i] > marks[best])
best = i;
return best;
}
int index_of_lowest(const float *marks, int n)
{
int worst = 0;
for (int i = 1; i < n; i++)
if (marks[i] < marks[worst])
worst = i;
return worst;
}
void print_report(const float *marks, int n)
{
float avg = total_of(marks, n) / n;
int hi = index_of_highest(marks, n);
int lo = index_of_lowest(marks, n);
int above = 0;
for (int i = 0; i < n; i++)
if (marks[i] > avg) above++;
printf("\n--- Report for %d students ---\n", n);
printf("Average : %.2f\n", avg);
printf("Highest : %.2f (student %d)\n", marks[hi], hi + 1);
printf("Lowest : %.2f (student %d)\n", marks[lo], lo + 1);
printf("Above average: %d\n", above);
}
int main(void)
{
int n;
printf("Number of students: ");
if (scanf("%d", &n) != 1 || n <= 0) {
printf("Not a valid count.\n");
return 1;
}
/* The size is known only now, so allocate now. */
float *marks = malloc(n * sizeof(float));
if (marks == NULL) {
printf("Could not allocate.\n");
return 1;
}
read_marks(marks, n);
print_report(marks, n);
free(marks);
marks = NULL;
return 0;
}
$ ./practical8
Number of students: 5
Marks of student 1: 72.5
Marks of student 2: 88
Marks of student 3: 45.5
Marks of student 4: 91
Marks of student 5: 63.5
--- Report for 5 students ---
Average : 72.10
Highest : 91.00 (student 4)
Lowest : 45.50 (student 3)
Above average: 3
main stays short because every step is a named function — read n, allocate, fill, report, free. That is the Module 5 habit, still paying.
- The helper functions take
const float *marks: they read the array, so const lets the compiler stop them from writing to it by accident.
- The count
n travels with the pointer into every function, because a pointer carries no length — the Module 6 lesson, unchanged.
💡Read the file for what is missing. There is no [50], no "maximum students" constant, no wasted slots and no upper limit — 5 students or 5,000, the program allocates exactly what it needs and frees it. That, and not the syntax, is what malloc is for.
09
Structures
- Building your own type out of the built-in ones — a record, not a pile of parallel arrays
- Arrays of structs, structs inside structs, and reaching through a pointer with
->
typedef to give the type a short name, union to overlap members, enum to name a set of values
- Why
sizeof a struct is usually more than the sum of its parts
Day 15 · Module 9 · Structures
Today we cover
- Why an array is the wrong shape for a student, an employee or a bank account
- Declaring a
struct, creating variables of it, and initialising them
- Arrays of structs — the shape almost every record-keeping program has
- A struct inside a struct, and reaching in one dot at a time
- Struct pointers, the
-> operator, and why you pass a struct by address
By the end of today you can design a record for a real-world thing, hold a hundred of them in an array, and write functions that read and change them without copying anything.
The problem a struct solves
Three arrays that must be kept in step by hand
whystruct.c · the old way
int roll[N] = {101, 102, 103};
char name[N][20] = {"Asha", "Rahul", "Fatima"};
float marks[N] = {87.5f, 74.0f, 93.5f};
/* Swap student 0 and 1 - and remember all THREE */
int ti = roll[0]; roll[0] = roll[1]; roll[1] = ti;
float tf = marks[0]; marks[0] = marks[1]; marks[1] = tf;
/* the names were forgotten: the records are now wrong */
for (int i = 0; i < N; i++)
printf("%d %-8s %.1f\n", roll[i], name[i], marks[i]);
$ ./whystruct
102 Asha 74.0
101 Rahul 87.5
103 Fatima 93.5
Look at the first two rows. Roll 102 now belongs to Asha and carries Rahul's marks. Nothing crashed, nothing warned — the data is simply wrong, because the three arrays are only related by a convention the programmer has to remember.
The three values are one thing. A student has a roll number, a name and marks. Splitting them across three arrays stores the pieces but throws away the fact that they belong together, and then asks you to maintain that fact by hand in every loop, every swap and every function call.
A struct is a type you define. int and float are given to you; a struct lets you build struct Student out of them and then use it the way you use any other type — declare one, put it in an array, pass it to a function, return it.
| Array | Struct |
| Many values of one type | One value made of many types |
Reached by index — a[2] | Reached by name — s.marks |
| Cannot be assigned as a whole | Can be assigned as a whole |
| Decays to a pointer when passed | Is copied when passed |
💡The two are used together, not instead of each other. The shape that actually solves this slide is an array of structs — one array, each element a complete student. Swapping two of them is then a single three-line swap that cannot forget a field.
Defining your own type
Declare the shape, then make variables of it
struct Student { /* the shape - no memory yet */
int roll;
char name[30];
float marks;
}; /* the semicolon is required */
struct Student s1 = {101, "Asha Menon", 87.5f};
struct Student s2;
s2.roll = 102;
snprintf(s2.name, sizeof(s2.name), "Rahul Verma");
s2.marks = 74.0f;
printf("%d %-14s %.1f\n", s1.roll, s1.name, s1.marks);
printf("%d %-14s %.1f\n", s2.roll, s2.name, s2.marks);
struct Student copy = s1; /* whole-struct assignment */
copy.marks = 91.0f;
printf("%d %-14s %.1f\n", copy.roll, copy.name, copy.marks);
printf("s1 is untouched: %.1f\n", s1.marks);
$ ./st
101 Asha Menon 87.5
102 Rahul Verma 74.0
101 Asha Menon 91.0
s1 is untouched: 87.5
- The declaration creates no memory.
struct Student { … }; describes a shape. Memory appears only when you declare a variable of that type.
- The trailing semicolon.
}; — forgetting it produces a confusing error on the next line, which is how most people meet it.
- The members are reached with a dot.
s1.roll, s1.marks. Each behaves exactly like an ordinary variable of its own type.
- Brace initialisation is positional —
{101, "Asha Menon", 87.5f} fills roll, name, marks in declaration order. Any member left out is set to zero.
- A whole struct can be assigned with
=, which copies every member, the character array included. This is the one thing an array itself cannot do.
Why snprintf and not s2.name = "Rahul Verma";? Because name is an array, and an array is not assignable — Module 6's rule, unchanged by being inside a struct. The text has to be copied into it, with strcpy, snprintf, or a scanf straight into s2.name.
💡copy = s1; is a real copy, not a second name for it. Changing copy.marks leaves s1.marks at 87.5. Remember this when you pass a struct to a function — the function gets exactly this kind of copy.
The record-keeping shape
One array, each element a whole record
struct Student cls[N] = {
{101, "Asha", 87.5f},
{102, "Rahul", 74.0f},
{103, "Fatima", 93.5f},
{104, "Vikram", 61.0f},
{105, "Neha", 82.5f}
};
stars.c · one pass over the array
float total = 0.0f;
int top = 0;
printf("ROLL NAME MARKS\n");
for (int i = 0; i < N; i++) {
printf("%-5d %-8s %5.1f\n",
cls[i].roll, cls[i].name, cls[i].marks);
total += cls[i].marks;
if (cls[i].marks > cls[top].marks)
top = i;
}
printf("\nClass average : %.2f\n", total / N);
printf("Topper : %s (%.1f)\n",
cls[top].name, cls[top].marks);
$ ./stars
ROLL NAME MARKS
101 Asha 87.5
102 Rahul 74.0
103 Fatima 93.5
104 Vikram 61.0
105 Neha 82.5
Class average : 79.70
Topper : Fatima (93.5)
cls[i] is one complete student; cls[i].marks is that student's marks. Index first, then dot.
- The nested braces mirror the shape: an outer pair for the array, an inner pair per element.
top holds an index, not a copy — so cls[top].name and cls[top].marks are guaranteed to be the same student. That is the bug from the previous slide, made impossible.
Swapping two records is now one swap. struct Student t = cls[0]; cls[0] = cls[1]; cls[1] = t; moves every member together. No field can be forgotten, because no field is handled separately.
💡This is the shape of almost every small C program that keeps records — a marksheet, a bank, an inventory, a library. Module 8's malloc replaces [N] when the count is not known in advance: struct Student *cls = malloc(n * sizeof(struct Student)); and everything else on this slide stays exactly the same.
A struct inside a struct
Reach in one dot at a time
struct Date {
int day;
int month;
int year;
};
struct Employee {
int id;
char name[25];
struct Date joined; /* a struct inside a struct */
float salary;
};
struct Employee e = {
7001, "Priya Nair",
{14, 8, 2023}, /* inner braces build the Date */
58000.0f
};
printf("Joined : %02d/%02d/%d\n",
e.joined.day, e.joined.month, e.joined.year);
e.joined.year = 2024; /* reach in one dot at a time */
printf("Revised : %02d/%02d/%d\n",
e.joined.day, e.joined.month, e.joined.year);
$ ./nested
ID : 7001
Name : Priya Nair
Joined : 14/08/2023
Salary : 58000.00
Revised : 14/08/2024
e.joined is a whole struct Date; e.joined.year is the int inside it. Each dot takes you one level down.
- The inner type must be declared first.
struct Employee cannot contain a struct Date the compiler has not seen yet.
e.joined = (struct Date){1, 1, 2025}; replaces the whole date at once, because a struct is assignable as a whole.
A struct cannot contain itself. struct Node { struct Node next; }; is impossible — the size would be infinite. It can contain a pointer to itself, struct Node *next;, because a pointer's size is known. That single distinction is what makes linked lists possible, and it is where Module 11 begins.
💡Nesting is how real records are built. An address inside a customer, a date inside an order, a point inside a rectangle. Each inner struct stays a reusable type in its own right — the same struct Date serves the joining date, the date of birth and the invoice date.
Module 7, applied to your own type
One operator that means “follow, then take”
stptr.c · the two notations
struct Student s = {101, "Asha", 87.5f};
struct Student *p = &s;
printf("p->roll = %d\n", p->roll);
printf("(*p).roll = %d\n", (*p).roll);
printf("sizeof(s) = %zu bytes\n", sizeof(s));
printf("sizeof(p) = %zu bytes\n", sizeof(p));
$ ./stptr
p->roll = 101
(*p).roll = 101
sizeof(s) = 28 bytes
sizeof(p) = 8 bytes
They are the same thing. p->roll is defined to mean exactly (*p).roll — follow the pointer to the struct, then take the member. -> exists only because the parentheses are unavoidable and everybody got tired of typing them.
| You have | Write |
A struct — struct Student s; | s.roll |
A pointer — struct Student *p; | p->roll |
| An array element | cls[i].roll |
| A pointer into an array | p->roll, then p++ |
Why *p.roll does not work. . binds tighter than *, so *p.roll parses as *(p.roll) — "take the member roll of the pointer p", which is meaningless, because a pointer has no members. Hence (*p).roll, and hence ->.
28 against 8. The struct is 28 bytes; a pointer to it is 8 on a 64-bit machine, whatever the struct holds. That single comparison is the argument of the next slide.
💡You will write -> constantly from here on. Every linked list, tree and queue in Module 11 is a chain of struct pointers, and every step through one is a ->. It is worth saying out loud once: "arrow means follow the pointer, then take the field."
Copy, or address?
A struct is copied — unless you pass its address
/* Takes a COPY - 28 bytes pushed per call,
and the caller's student is untouched. */
void by_value(struct Student s)
{
s.marks = 100.0f;
printf(" inside by_value : %.1f\n", s.marks);
}
/* Takes an ADDRESS - 8 bytes, and it can
change the caller's data. */
void by_pointer(struct Student *p)
{
p->marks = 100.0f;
printf(" inside by_pointer: %.1f\n", p->marks);
}
$ ./stptr
inside by_value : 100.0
after by_value : 101 Asha 87.5
inside by_pointer: 100.0
after by_pointer : 101 Asha 100.0
Both functions really did set 100.0. The difference is what they set it on. by_value changed its own private copy, which was destroyed on return. by_pointer followed the address back to main's student and changed that. This is Day 8's failed swap and Day 12's fix, in new clothes.
The rule in practice. Pass const struct Student * when the function only reads — it avoids copying 28 bytes and the const makes the compiler enforce "read only". Pass struct Student * when it must change the record. Pass by value only for genuinely small structs you deliberately want a copy of.
| struct Student s | struct Student *p |
| What is passed | A copy of all 28 bytes | An 8-byte address |
| Member access | s.marks | p->marks |
| Can change the caller's record | No | Yes — unless declared const |
| Cost grows with the struct | Yes | No — always one address |
💡Returning a struct is allowed, unlike returning an array — struct Date today(void) works fine and hands back a copy. The array inside a struct travels with it, which is why a struct can be returned when a bare array cannot.
Day 16 · Module 9 · Structures
Today we cover
typedef — giving a type a shorter name, and the usual struct idiom
union — members that share one block of memory, and what that is actually for
enum — naming a fixed set of values instead of scattering 1, 2, 3 through the code
- Padding: why
sizeof a struct is usually larger than the sum of its members
By the end of today you can write Student s; instead of struct Student s;, explain when a union is the right tool and when it is a trap, and predict roughly what sizeof will say about a struct you designed.
A shorter name for a type
Dropping the word struct
/* A name for a plain type ... */
typedef unsigned int uint;
/* ... and, far more usefully, for a struct. */
typedef struct {
int roll;
char name[20];
float marks;
} Student;
/* The parameter type is now just "Student". */
void show(const Student *s)
{
printf("%d %-8s %.1f\n", s->roll, s->name, s->marks);
}
int main(void)
{
uint count = 2;
Student a = {101, "Asha", 87.5f}; /* no "struct" */
Student b = {102, "Rahul", 74.0f};
show(&a);
show(&b);
printf("%u students\n", count);
return 0;
}
$ ./td
101 Asha 87.5
102 Rahul 74.0
2 students
typedef creates no new type. It creates a new name for an existing one. uint and unsigned int are the same type in every respect; the compiler cannot tell them apart and will not warn if you mix them.
Without typedef | With |
struct Student s; | Student s; |
struct Student *p; | Student *p; |
void f(struct Student *s) | void f(Student *s) |
The tag can be kept as well. typedef struct Student { … } Student; gives you both struct Student and Student. You need the tag when the struct refers to itself: a node's next pointer must be written struct Node *next; inside the braces, because the typedef name does not exist yet at that point.
You have used one already. size_t, FILE and time_t are all typedefs from the standard library — which is exactly why you write FILE *fp; and not struct FILE *fp;.
💡Use it for structs; be sparing elsewhere. Hiding unsigned int behind uint saves six characters and costs the reader the knowledge of what the type really is. Hiding a pointer inside a typedef — typedef struct Node *NodePtr; — is worse: readers stop seeing that a pointer is involved at all.
Members that share one space
Same syntax as a struct, opposite storage
un.c · same members, two ways
struct SData { int i; float f; char c; };
union UData { int i; float f; char c; };
union UData u;
printf("sizeof(struct SData) = %zu\n", sizeof(struct SData));
printf("sizeof(union UData) = %zu\n", sizeof(union UData));
u.i = 65;
printf("\nwrote u.i = 65\n");
printf(" u.i = %d\n", u.i);
printf(" u.c = %c\n", u.c);
u.f = 3.14f;
printf("\nwrote u.f = 3.14 (same bytes, overwritten)\n");
printf(" u.f = %.2f\n", u.f);
printf(" u.i = %d\n", u.i);
return 0;
}
$ ./un
sizeof(struct SData) = 12
sizeof(union UData) = 4
wrote u.i = 65
u.i = 65
u.c = A
wrote u.f = 3.14 (same bytes, overwritten)
u.f = 3.14
u.i = 1078523331
| struct | union |
| Members | Each has its own memory | All share one block |
| Size | Sum of members, plus padding | Size of the largest member |
| Valid at once | All of them | One — the one written last |
| Use for | A record with several fields | A value that is one of several kinds |
Read the output carefully. After u.i = 65, reading u.c gives 'A' — because 65 is the code for 'A' and c is looking at the same first byte. After u.f = 3.14, u.i reports 1078523331: not a conversion, just the bit pattern of 3.14 read as though it were an integer.
The rule. Read back only the member you wrote last. Reading any other one gives you a reinterpretation of raw bytes, which is a bug in every program that did not mean to do it deliberately.
💡What a union is actually for. A value that is one of several kinds at a time — a token that is either a number or a name, a setting that is either an int or a string. The standard shape pairs it with an enum tag inside a struct, so the program can always say which member is the live one. Unions also appear in embedded code to view one block of memory two ways, which is exactly the trick this slide's output shows.
Naming a fixed set of values
Names instead of bare numbers
enum Weekday { MON = 1, TUE, WED, THU, FRI, SAT, SUN };
void schedule(enum Weekday d)
{
switch (d) {
case SAT:
case SUN:
printf("%d: holiday\n", d);
break;
case WED:
printf("%d: lab day\n", d);
break;
default:
printf("%d: class day\n", d);
}
}
int main(void)
{
printf("MON = %d, TUE = %d, SUN = %d\n", MON, TUE, SUN);
for (enum Weekday d = MON; d <= SUN; d++)
schedule(d);
return 0;
}
$ ./en
MON = 1, TUE = 2, SUN = 7
1: class day
2: class day
3: lab day
4: class day
5: class day
6: holiday
7: holiday
- The values are just
ints. Numbering starts at 0 unless you say otherwise; here MON = 1 sets the start and the rest follow — TUE is 2, SUN is 7.
- Any value can be pinned —
enum Status { OK = 0, WARN = 10, FAIL = 20 }; — and the ones after it carry on from there.
- The names are constants, not strings.
printf("%d", MON) prints 1; there is no built-in way to print the word "MON". A parallel array of strings is the usual answer.
Why not #define MON 1? Both give you a name for 1, but an enum declares a set that belongs together, gives the debugger something to show you, and lets the compiler warn when a switch forgets one of its values. The preprocessor's #define has no idea the seven days are related — Module 10 returns to this.
💡The pairing to remember. An enum for the kind, a switch for the behaviour. The bank practical two slides from here uses enum TxnKind { DEPOSIT = 1, WITHDRAW }; so that a transaction record says DEPOSIT and not 1 — and so the statement printer cannot mistake one for the other.
Why the numbers do not add up
Same members, different order, different size
#include <stddef.h> /* for offsetof */
struct Loose {
char a; /* 1 byte, then 3 wasted */
int b; /* 4 */
char c; /* 1 byte, then 3 wasted */
};
struct Tight {
int b; /* 4 */
char a; /* 1 */
char c; /* 1, then 2 wasted */
};
printf("sizeof(struct Loose) = %zu\n", sizeof(struct Loose));
printf("sizeof(struct Tight) = %zu\n", sizeof(struct Tight));
printf("sum of the members = %zu\n",
sizeof(char) + sizeof(int) + sizeof(char));
printf("\nLoose offsets: a=%zu b=%zu c=%zu\n",
offsetof(struct Loose, a), offsetof(struct Loose, b),
offsetof(struct Loose, c));
printf("Tight offsets: b=%zu a=%zu c=%zu\n",
offsetof(struct Tight, b), offsetof(struct Tight, a),
offsetof(struct Tight, c));
$ ./pad
sizeof(struct Loose) = 12
sizeof(struct Tight) = 8
sum of the members = 6
Loose offsets: a=0 b=4 c=8
Tight offsets: b=0 a=4 c=5
The members are identical. One char, one int, one char — six bytes of actual data either way. Rearranging them changes the size by a third, because of what the compiler puts between them.
- Alignment. A 4-byte
int must sit at an address that is a multiple of 4 — the CPU reads it in one go that way. So in Loose, a takes byte 0 and b cannot start until byte 4: three bytes are skipped.
- Tail padding. The struct's own size is rounded up to a multiple of its largest member's alignment, so that an array of them keeps every element aligned. That is the 3 wasted bytes after
Loose.c and the 2 after Tight.c.
- The offsets prove it —
offsetof reports where each member really starts. In Tight, a and c sit side by side at 4 and 5 with nothing between them.
These exact numbers are this compiler's. They come from 64-bit GCC on Windows, where int is 4 bytes and aligns to 4. Another compiler, another platform, or a packing option can give different sizes — the rule is portable, the numbers are not. Never assume a struct's size; ask with sizeof.
💡Declare members largest-first and padding mostly disappears by itself. It rarely matters for a handful of records — but for an array of a million, the Loose layout costs 4 MB more than Tight for exactly the same data, and every one of those wasted bytes is read into cache along with the real ones.
Module 9 practical
A bank built from struct, enum and pointers
#define MAX_ACC 10
#define MAX_TXN 20
enum TxnKind { DEPOSIT = 1, WITHDRAW };
struct Txn {
enum TxnKind kind;
float amount;
float balance_after;
};
struct Account {
int number;
char holder[25];
float balance;
struct Txn history[MAX_TXN];
int txn_count;
};
int find_account(const struct Account *acc, int count, int number)
{
for (int i = 0; i < count; i++)
if (acc[i].number == number)
return i;
return -1;
}
void record(struct Account *a, enum TxnKind kind, float amount)
{
if (a->txn_count >= MAX_TXN)
return;
a->history[a->txn_count].kind = kind;
a->history[a->txn_count].amount = amount;
a->history[a->txn_count].balance_after = a->balance;
a->txn_count++;
}
void deposit(struct Account *a, float amount)
{
if (amount <= 0) {
printf("Amount must be positive.\n");
return;
}
a->balance += amount;
record(a, DEPOSIT, amount);
printf("Deposited %.2f. Balance = %.2f\n", amount, a->balance);
}
void withdraw(struct Account *a, float amount)
{
if (amount <= 0) {
printf("Amount must be positive.\n");
return;
}
if (amount > a->balance) {
printf("Insufficient funds. Balance is only %.2f\n",
a->balance);
return;
}
a->balance -= amount;
record(a, WITHDRAW, amount);
printf("Withdrew %.2f. Balance = %.2f\n", amount, a->balance);
}
void statement(const struct Account *a)
{
printf("\nAccount %d - %s\n", a->number, a->holder);
printf("TYPE AMOUNT BALANCE\n");
for (int i = 0; i < a->txn_count; i++)
printf("%-9s %8.2f %9.2f\n",
a->history[i].kind == DEPOSIT ? "DEPOSIT" : "WITHDRAW",
a->history[i].amount,
a->history[i].balance_after);
printf("Closing balance: %.2f\n", a->balance);
}
/* main holds the array, reads the menu choice, looks the
account up with find_account, and hands &acc[i] to the
function that does the work. */
$ ./bank
1. Deposit 2. Withdraw 3. Balance 4. Statement 5. Exit
Enter choice: 1
Account number: 5001
Amount: 2500
Deposited 2500.00. Balance = 7500.00
1. Deposit 2. Withdraw 3. Balance 4. Statement 5. Exit
Enter choice: 2
Account number: 5001
Amount: 10000
Insufficient funds. Balance is only 7500.00
1. Deposit 2. Withdraw 3. Balance 4. Statement 5. Exit
Enter choice: 2
Account number: 5001
Amount: 2000
Withdrew 2000.00. Balance = 5500.00
1. Deposit 2. Withdraw 3. Balance 4. Statement 5. Exit
Enter choice: 4
Account number: 5001
Account 5001 - Asha Menon
TYPE AMOUNT BALANCE
DEPOSIT 2500.00 7500.00
WITHDRAW 2000.00 5500.00
Closing balance: 5500.00
1. Deposit 2. Withdraw 3. Balance 4. Statement 5. Exit
Enter choice: 5
Goodbye.
- Every piece of Module 9 is here. A struct holding another struct's array (
struct Txn history[] inside struct Account), an array of structs, an enum for the transaction kind, and -> on every line that touches an account.
- Nothing is passed by value.
deposit and withdraw take struct Account * because they must change the record; statement and find_account take const struct Account * because they must not.
- The guard is the point of the exercise.
if (amount > a->balance) refuses the withdrawal and returns without touching the balance — the account is never left in a state that the history cannot explain.
💡Extend it three ways. Replace the fixed acc[MAX_ACC] with a malloced array read from the user (Module 8), save the accounts to a file so the balances survive a restart (Module 10, next), and turn the transaction history into a linked list so it is never full (Module 11). The bank is the running example those three modules extend.
10
Files & the preprocessor
- Making data outlive the program — opening, writing, reading and closing a file
- Text files against binary files, and when each is the right choice
- Jumping straight to record 3 with
fseek, instead of reading the first two
- What the preprocessor really does before the compiler ever sees your code
- Macros, include guards, and splitting a program across more than one
.c file
Day 17 · Module 10 · Files & the preprocessor
Today we cover
- Everything so far has died when the program ended — today it stops dying
FILE *, the six fopen modes, and why fclose is not optional
- Writing and reading text with
fprintf and fgets; whole structs with fwrite and fread
fseek, ftell and rewind — reaching one record without reading the rest
- The preprocessor:
#include, #define, macros with arguments, and include guards
By the end of today you can write a program that saves its records to a file and finds them again the next time it runs — and explain what the compiler saw after the preprocessor finished with your source.
The handle and the four steps
Open, check, use, close
the shape of every file program
#include <stdio.h>
FILE *fp = fopen("notes.txt", "r"); /* 1. open */
if (fp == NULL) { /* 2. CHECK */
perror("notes.txt");
return 1;
}
/* 3. use it - fprintf, fgets, fread, fwrite ... */
fclose(fp); /* 4. close */
FILE * is a handle, not the file. fopen asks the operating system to open the file and hands back a pointer to a FILE structure holding the position, the buffer and the error flags. You never look inside it — you only pass it to the f… functions. FILE is a typedef, which is why it needs no struct keyword.
| Mode | Meaning | If the file is missing |
"r" | Read from the start | Fails — returns NULL |
"w" | Write — empties the file first | Creates it |
"a" | Append — always writes at the end | Creates it |
"r+" | Read and write, from the start | Fails |
"w+" | Read and write, empties first | Creates it |
"a+" | Read anywhere, write at the end | Creates it |
Add b for binary — "rb", "wb", "ab". On Windows this matters: in text mode a written \n becomes two bytes on disk and is translated back on reading. For a file of structs that silent translation corrupts the data, so binary files must always be opened with b.
💡"w" deletes the file's contents the moment it opens. Not when you write — when you open. A program that opens a file with "w" to "check something" has already destroyed it. When you mean "add to this file", the mode is "a". And fclose is what flushes the buffer: data you "wrote" may still be sitting in memory, so a program that skips fclose and crashes writes nothing at all.
Readable files
The same printf you know, aimed at a file
FILE *fp = fopen("notes.txt", "w");
if (fp == NULL) {
printf("Could not open notes.txt\n");
return 1;
}
fprintf(fp, "Asha 87.5\n");
fprintf(fp, "Rahul 74.0\n");
fputs("Fatima 93.5\n", fp);
fclose(fp);
printf("notes.txt written\n");
$ ./write · $ cat notes.txt
notes.txt written
Asha 87.5
Rahul 74.0
Fatima 93.5
char line[100];
int n = 0;
FILE *fp = fopen("notes.txt", "r");
if (fp == NULL) return 1;
/* fgets stops at the newline OR at size-1
characters, and returns NULL at the end. */
while (fgets(line, sizeof(line), fp) != NULL) {
n++;
printf("%d: %s", n, line);
}
fclose(fp);
printf("%d lines read\n", n);
$ ./readit
1: Asha 87.5
2: Rahul 74.0
3: Fatima 93.5
3 lines read
| Console | File |
printf(…) | fprintf(fp, …) |
scanf(…) | fscanf(fp, …) |
puts(s) | fputs(s, fp) |
getchar() | fgetc(fp) |
Why fgets and not fscanf("%s"). %s stops at the first space, so "Asha Menon" arrives as two separate reads. fgets takes the whole line, and it is given the buffer's size, so it cannot overflow. Read the line with fgets, then pick it apart with sscanf — the pattern the practical uses.
fgets keeps the newline. That is why the loop prints "%d: %s" with no \n of its own. To strip it: line[strcspn(line, "\n")] = '\0';
💡A text file is just bytes a human can read. Nothing separates notes.txt from what you would type into Notepad — which is the great advantage: you can open it, read it, fix it by hand, mail it, put it in Excel. The cost is size and precision, which is what the next slide is about.
Whole structs, straight to disk
The bytes in memory, written as they are
bin.c · write then read back
struct Student out[3] = {
{101, "Asha", 87.5f},
{102, "Rahul", 74.0f},
{103, "Fatima", 93.5f}
};
struct Student in[3];
FILE *fp = fopen("students.dat", "wb"); /* b = binary */
if (fp == NULL) return 1;
fwrite(out, sizeof(struct Student), 3, fp);
fclose(fp);
fp = fopen("students.dat", "rb");
if (fp == NULL) return 1;
size_t got = fread(in, sizeof(struct Student), 3, fp);
fclose(fp);
$ ./bin
records read: 3
one record : 28 bytes
101 Asha 87.5
102 Rahul 74.0
103 Fatima 93.5
Both take four arguments — fwrite(where, size of one, how many, fp). They return the number of items actually handled, not bytes, so got == 3 is the check that the read succeeded. A short count means the file ended early.
| Text | Binary |
| One record here | ~20 readable bytes | Exactly 28 — sizeof |
| Human-readable | Yes | No |
| Float precision | Only what you printed | Exact, bit for bit |
| Writing a struct | Field by field | One fwrite |
| Jump to record n | Must scan the lines | n * sizeof(rec) |
| Portable between machines | Yes | No |
💡The portability warning is the real one. A binary file is a photograph of your memory, padding and all — so it is only readable by a program built with the same compiler, the same struct definition and the same word size. Change one field of the struct and every old file becomes unreadable. Use binary for a program's own working data; use text when anything else will ever read it.
Random access
Reach record 3 without reading 1 and 2
FILE *fp = fopen("students.dat", "rb");
if (fp == NULL) return 1;
fseek(fp, 0, SEEK_END); /* jump to the end ... */
size = ftell(fp); /* ... and ask where that is */
printf("file size : %ld bytes\n", size);
printf("record count : %ld\n",
size / (long)sizeof(struct Student));
/* Read ONLY the 3rd record. */
fseek(fp, 2L * (long)sizeof(struct Student), SEEK_SET);
printf("offset now : %ld\n", ftell(fp));
fread(&s, sizeof(struct Student), 1, fp);
printf("record 3 : %d %s %.1f\n", s.roll, s.name, s.marks);
rewind(fp); /* back to byte 0 */
printf("after rewind : %ld\n", ftell(fp));
fclose(fp);
$ ./seek
file size : 84 bytes
record count : 3
offset now : 56
record 3 : 103 Fatima 93.5
after rewind : 0
| Call | What it does |
fseek(fp, n, SEEK_SET) | Move to byte n from the start |
fseek(fp, n, SEEK_CUR) | Move n bytes from where you are |
fseek(fp, n, SEEK_END) | Move n bytes from the end |
ftell(fp) | Report the current byte offset |
rewind(fp) | Back to byte 0, and clear the error flags |
- The arithmetic is the whole idea. Every record is the same size, so record n begins at
n * sizeof(struct Student). Record 3 is index 2, and 2 × 28 = 56 — exactly the offset the program reports.
SEEK_END then ftell gives you the file's size, and dividing by the record size gives the record count without a loop.
n is a long. Casting sizeof keeps the multiplication honest on a large file.
💡This is what a database does, in miniature. Fixed-size records plus an offset calculation means finding record 900,000 costs exactly what finding record 3 costs. It also means you can overwrite one record in place: fseek to it, fwrite the new version, and nothing else in the file moves. Text files cannot do that — changing one line means rewriting everything after it.
When the file is not there
The check you cannot skip, and the message the system gives you
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
FILE *fp = fopen("no_such_file.txt", "r");
if (fp == NULL) {
/* perror prints your message, then ": ", then
the system's description of what went wrong. */
perror("no_such_file.txt");
exit(1);
}
printf("opened - this line never runs\n");
fclose(fp);
return 0;
}
$ ./err · exit status 1
no_such_file.txt: No such file or directory
The second half of that line is not yours. You supplied "no_such_file.txt"; perror added ": No such file or directory" by reading errno, the global the library sets when a call fails. A wrong-permissions file would produce ": Permission denied" from the identical line of code.
| Check | Meaning |
fp == NULL | The open failed — missing, locked, or no permission |
feof(fp) | The end of the file has been reached |
ferror(fp) | A read or write failed — a real error, not the end |
Return of fread | Fewer items than asked for — short read |
Why while (!feof(fp)) is wrong. feof only becomes true after a read has already failed at the end, so the loop runs one extra time with stale data. Test the read itself instead — while (fgets(…) != NULL) or while (fread(…) == 1) — which is what every loop in this module does.
💡Three lines, every single time. Opening a file is the most likely thing in a small C program to fail, because it depends on the world outside the program — a typo in a name, a folder that moved, a file someone else has open. exit(1) ends the program with a non-zero status, which is how a script or a shell knows it failed.
Before the compiler runs
A text-substitution pass that knows no C at all
Day 1's pipeline, revisited. The preprocessor is the first stage: it reads your .c file, obeys every line starting with #, and hands the compiler one long expanded file. It does not understand types, expressions or scope — it moves text around. Every confusing macro bug comes from that one fact.
| Directive | What it does |
#include <stdio.h> | Pastes that file in here. Angle brackets: search the system folders |
#include "marks.h" | Same, but look in this folder first |
#define PI 3.14159 | Replace every later PI with that text |
#undef PI | Forget that definition |
#ifdef / #ifndef | Keep the block only if a name is / is not defined |
#if / #else / #endif | Choose which block the compiler even sees |
$ gcc -E hello.c > hello.i # stop after preprocessing
$ wc -l hello.c # 6 lines you wrote
$ wc -l hello.i # ~800 - stdio.h pasted in
No semicolons, and the line is the statement. A directive ends at the end of the line, not at a ;. Writing #define MAX 100; makes every MAX expand to 100;, so int a[MAX]; becomes int a[100;]; and the error appears nowhere near the #define.
#define versus const. const int MAX = 100; is a real variable with a type the compiler checks and a name the debugger knows. #define MAX 100 is a find-and-replace with no type at all. Prefer const for values, and keep #define for array sizes, conditional compilation and include guards — the jobs const cannot do.
💡By convention macro names are SHOUTED. MAX_ACC, PASS_MARK, FILENAME. It is only a convention, and the compiler does not enforce it — but it is the reader's only warning that a name is text substitution rather than a variable, so it is worth keeping.
The classic preprocessor bug
Two macros, one character apart, different answers
#define SQUARE(x) x * x
int a = 2, b = 3;
printf("SQUARE(5) = %d\n", SQUARE(5));
printf("SQUARE(a + b) = %d\n", SQUARE(a + b));
printf("expected = %d\n", (a + b) * (a + b));
$ ./macro_bad
SQUARE(5) = 25
SQUARE(a + b) = 11
expected = 25
#define SQUARE(x) ((x) * (x))
int a = 2, b = 3;
printf("SQUARE(5) = %d\n", SQUARE(5));
printf("SQUARE(a + b) = %d\n", SQUARE(a + b));
printf("expected = %d\n", (a + b) * (a + b));
$ ./macro_good
SQUARE(5) = 25
SQUARE(a + b) = 25
expected = 25
Where 11 came from. The preprocessor does not evaluate a + b and square the result — it substitutes the text. SQUARE(a + b) becomes a + b * a + b, and now C's ordinary precedence applies: 2 + (3 × 2) + 3 = 11. Nothing warned, nothing crashed, and both programs compiled cleanly — the only symptom is a wrong number. Wrapping every argument, ((x) * (x)), forces the addition to happen first and the answer back to 25.
The second trap: double evaluation. SQUARE(i++) expands to ((i++) * (i++)) — i is incremented twice, and the result is undefined behaviour on top. A macro's argument must never be an expression with a side effect, and there is nothing the macro's author can do about it.
The rules, then. Parenthesise every argument and the whole body. Shout the name. Keep it to one expression. And in modern C, prefer a small function — int square(int x) { return x * x; } has none of these problems, is type-checked, and the compiler will usually inline it anyway.
💡When a macro still wins. When it must work for any type at once — #define MAX(a,b) ((a) > (b) ? (a) : (b)) works on ints, floats and chars, where a function would need one version each. That genuine advantage is why macros survive, and why their traps are worth knowing rather than avoiding.
More than one .c file
Declarations in the header, bodies in the source
#ifndef MARKS_H
#define MARKS_H
#define PASS_MARK 40
/* declarations only */
float average(const float *a, int n);
int passed(const float *a, int n);
#endif /* MARKS_H */
#include "marks.h"
float average(const float *a, int n)
{
float sum = 0.0f;
for (int i = 0; i < n; i++)
sum += a[i];
return sum / n;
}
int passed(const float *a, int n)
{
int count = 0;
for (int i = 0; i < n; i++)
if (a[i] >= PASS_MARK)
count++;
return count;
}
#include <stdio.h>
#include "marks.h"
#include "marks.h" /* twice on purpose */
int main(void)
{
float m[5] = {87.5f, 34.0f, 93.5f,
61.0f, 38.5f};
printf("pass mark : %d\n", PASS_MARK);
printf("average : %.2f\n", average(m, 5));
printf("passed : %d of 5\n", passed(m, 5));
return 0;
}
$ gcc -Wall -Wextra -std=c11 use.c marks.c -o use · $ ./use
pass mark : 40
average : 62.90
passed : 3 of 5
The include guard. The first time marks.h is pasted in, MARKS_H is not defined, so the body is kept — and defines MARKS_H. The second #include finds it already defined and the preprocessor throws the whole block away. Without it, use.c would declare everything twice, which for a struct or typedef is a compile error. Real headers include each other constantly, so every header gets one.
Compile both, and only name the .c files. gcc use.c marks.c -o use — the header is never passed to gcc, because it has already been pasted into both sources by the preprocessor. Leave marks.c off the command and the compiler is happy but the linker fails with "undefined reference to average": it saw the promise in the header and never found the body. That is Day 2's linker error, with a cause you can now name.
💡The header is the contract. It tells every other file what exists and how to call it; the .c file decides how. Change a body and only that one file recompiles; change the header and everything that includes it must. That single split is how a program grows past one file without becoming unreadable — and it is exactly what <stdio.h> has been doing for you since Day 1.
Module 10 practical
Records that are still there tomorrow
#define FILENAME "students.txt"
struct Student {
int roll;
char name[25];
float marks;
};
/* "a" creates the file if missing and always
writes at the end. */
int add_record(const struct Student *s)
{
FILE *fp = fopen(FILENAME, "a");
if (fp == NULL) {
perror(FILENAME);
return 0;
}
fprintf(fp, "%d,%s,%.1f\n",
s->roll, s->name, s->marks);
fclose(fp);
return 1;
}
int list_records(void)
{
struct Student s;
char line[100];
int n = 0;
float total = 0.0f;
FILE *fp = fopen(FILENAME, "r");
if (fp == NULL) {
printf("No records yet.\n");
return 0;
}
printf("\nROLL NAME MARKS\n");
while (fgets(line, sizeof(line), fp) != NULL) {
if (sscanf(line, "%d,%24[^,],%f",
&s.roll, s.name, &s.marks) != 3)
continue; /* skip a bad line */
printf("%-5d %-19s %5.1f\n",
s.roll, s.name, s.marks);
total += s.marks;
n++;
}
fclose(fp);
if (n > 0)
printf("%d records, average %.2f\n",
n, total / n);
return n;
}
Read the line, then pick it apart. fgets takes the whole line however many spaces the name has; sscanf then splits it on the commas. %24[^,] means "up to 24 characters that are not a comma" — which is how "Asha Menon" survives intact where %s would have stopped at the space.
$ ./practical10
1. Add record 2. List records 3. Exit
Enter choice: 1
Roll: 101
Name: Asha Menon
Marks: 87.5
Saved to students.txt
1. Add record 2. List records 3. Exit
Enter choice: 1
Roll: 102
Name: Rahul Verma
Marks: 74
Saved to students.txt
1. Add record 2. List records 3. Exit
Enter choice: 2
ROLL NAME MARKS
101 Asha Menon 87.5
102 Rahul Verma 74.0
2 records, average 80.75
1. Add record 2. List records 3. Exit
Enter choice: 3
Goodbye.
$ cat students.txt
101,Asha Menon,87.5
102,Rahul Verma,74.0
- Run it twice. The second run lists the records from the first — that is the whole point of the module, and the first program in this course whose output depends on what happened yesterday.
- Comma-separated text is a real format. This file is a CSV: Excel will open it, a Python script will read it, and you can fix a typo in Notepad. That is what text buys you over
fwrite.
- Every
fopen is checked and every one is closed, in both functions, including on the error paths — the file equivalent of Module 8's malloc/free discipline.
💡Two exercises worth doing. Add a "search by roll" option — it is list_records with an if. Then try "delete a record", and discover the real limitation of a text file: you cannot remove a line in place. The standard answer is to copy every line you are keeping into a temporary file, then remove the original and rename the temporary one over it.
11
Data structures in C
- The payoff module — structs, pointers and
malloc finally used together for what they were for
- Linked lists: a collection that grows one node at a time and never runs out of room
- Stacks and queues — two rules about which end you are allowed to touch, and what they buy you
- Doubly linked lists, and a first honest look at what "efficient" means
Day 18 · Module 11 · Data structures
Today we cover
- What a data structure actually is, and why an array is not always the right one
- The one declaration that makes all of this possible — a struct holding a pointer to itself
- Building a node with
malloc, and linking nodes into a chain
- Inserting at the front, at the end and in the middle — and why the head pointer keeps coming back
- Searching, deleting, walking the list, and freeing every node at the end
By the end of today you can build a linked list from nothing, add and remove items anywhere in it, and free the whole thing without leaking a single node.
Choosing the shape of your data
A way of holding data that makes some operations cheap
You have used one since Day 10. An array is a data structure: values laid out side by side, reached by index. It makes "give me item 500" instant and "insert at the front" expensive, because everything after has to shuffle up. A different layout makes a different set of operations cheap. That trade is the whole subject.
| Array | Linked list |
| One contiguous block | Separate nodes, joined by pointers |
| Size fixed at creation | Grows one node at a time, no limit |
a[500] is instant | Item 500 means 500 steps |
| Insert at front: shift everything | Insert at front: change one pointer |
| No memory per item wasted | 8 extra bytes per node for the link |
| Neighbours are next to each other | Nodes can be anywhere in memory |
The three questions to ask of any structure. How do I add something? How do I find something? How do I remove something? The answers, and what each costs, are what separate an array from a list from a stack from a queue — and they are all this module is about.
Two families. A linear structure lays its items in a single line: arrays, linked lists, stacks, queues — the ones on this course. A non-linear one branches: trees and graphs, where a node has several successors. Everything you learn here is what those are built from.
💡Why this module comes last. A linked list needs a struct that holds a pointer to its own type (Module 9), memory allocated while the program runs (Module 8), and a pointer walked with -> until it hits NULL (Module 7). Nothing here is a new language feature. It is the three you already have, finally pointed at a problem worth their trouble.
The declaration everything rests on
A struct that holds a pointer to its own type
struct Node {
int data;
struct Node *next; /* a pointer to its own type */
};
impossible — will not compile
struct Node {
int data;
struct Node next; /* NOT a pointer - infinite size */
};
Why one works and the other cannot. To lay out struct Node the compiler must know its size. If a Node contained a whole Node, that one would contain another, for ever — the size has no answer. A pointer to a Node is 8 bytes whatever it points at, and the compiler knows that before it knows anything else about the struct. The error message is field 'next' has incomplete type.
head
|
v
+------+------+ +------+------+ +------+------+
| 10 | o---+--> | 20 | o---+--> | 30 | NULL |
+------+------+ +------+------+ +------+------+
data next data next data next
head is just a pointer to the first node — not a node itself, and not the list. Lose head and the whole chain is unreachable and unfreeable.
NULL in the last node's next is the end marker. Every loop in this module is "walk until the pointer is NULL" — the same job the '\0' does in a string.
- An empty list is
head == NULL. Not a special case to be avoided — it is the state every list starts in, and every function has to handle it.
💡If you use a typedef, keep the tag. typedef struct Node { int data; struct Node *next; } Node; — inside the braces the name Node does not exist yet, so the field must still be written struct Node *next;. This is the case Module 9 warned about, and it is the one place it really bites.
One node at a time
Every node is a malloc, and every malloc is checked
struct Node *make_node(int value)
{
struct Node *n = malloc(sizeof(struct Node));
if (n == NULL) {
printf("Out of memory\n");
exit(1);
}
n->data = value;
n->next = NULL; /* a new node ends the list */
return n;
}
struct Node *head = make_node(10);
head->next = make_node(20);
head->next->next = make_node(30);
/* head -> 10 -> 20 -> 30 -> NULL */
- The node lives on the heap, so it outlives the function that made it. This is exactly the Module 8 point: a node built in a local variable would die on return, and the pointer handed back would dangle.
n->next = NULL; is not optional. A fresh malloc returns uninitialised memory, so next holds garbage. Walk a list whose last node has a garbage next and the loop follows it into memory that is not yours.
- Returning
struct Node * is why the caller can write head = make_node(10); — the function allocates, the caller owns.
Why exit(1) here rather than returning NULL? Because a teaching program has nothing sensible to do when the heap is exhausted, and checking one return value in one place keeps the list code readable. Real library code returns NULL and makes every caller handle it — which is more correct and would double the length of every slide that follows.
💡Chaining by hand once is worth doing. Type those three lines and print head->next->next->data. Seeing 30 come out of a chain of arrows makes every function on the next three slides read as a shortcut for something you already did manually.
Three places, three functions
Rewire two pointers, in the right order
struct Node *insert_front(
struct Node *head, int value)
{
struct Node *n = make_node(value);
n->next = head;
return n; /* n is the new head */
}
struct Node *insert_end(
struct Node *head, int value)
{
struct Node *n = make_node(value);
if (head == NULL)
return n; /* empty list */
struct Node *p = head;
while (p->next != NULL)
p = p->next; /* walk to the last */
p->next = n;
return head;
}
insert_at (after position)
struct Node *insert_at(
struct Node *head, int pos, int value)
{
if (pos <= 1 || head == NULL)
return insert_front(head, value);
struct Node *p = head;
for (int i = 1; i < pos - 1
&& p->next != NULL; i++)
p = p->next;
struct Node *n = make_node(value);
n->next = p->next; /* link forward FIRST */
p->next = n; /* then link in */
return head;
}
$ ./list
list: 10 -> 20 -> 30 -> NULL
list: 5 -> 10 -> 20 -> 30 -> NULL
list: 5 -> 10 -> 15 -> 20 -> 30 -> NULL
The order of those two lines is the whole bug. In insert_at, n->next = p->next; must come before p->next = n;. Do it the other way round and p->next has already been overwritten, so n->next points at n itself — the rest of the list is lost and any walk through it loops for ever.
Why every one of them returns the head. Inserting at the front makes a new first node, so the caller's head must change — and C passes by value, so the function cannot change the caller's variable by itself. Returning the head and writing head = insert_front(head, 5); is the simplest fix. The alternative is a struct Node **head parameter, which is Module 7's pointer-to-pointer doing the same job.
💡Front is free, end is not. insert_front does the same tiny amount of work whether the list holds three nodes or three million. insert_end has to walk the whole chain first. Keeping a second pointer to the last node — a tail pointer — makes appending as cheap as prepending, and is the first optimisation anyone makes to this code.
Finding, and unlinking
Keep a trailing pointer, or you cannot unlink anything
list.c · find it, one step behind
struct Node *delete_value(struct Node *head, int value)
{
struct Node *p = head, *prev = NULL;
while (p != NULL && p->data != value) {
prev = p; /* one step behind */
p = p->next;
}
if (p == NULL) {
printf("%d not found\n", value);
return head;
}
list.c · unlink, then free
if (prev == NULL) /* deleting the head */
head = p->next;
else
prev->next = p->next; /* jump over p */
free(p);
printf("%d deleted\n", value);
return head;
}
int search(const struct Node *head, int value)
{
int pos = 1;
for (const struct Node *p = head;
p != NULL; p = p->next, pos++)
if (p->data == value)
return pos;
return -1;
}
$ ./list
search 20 -> position 4
search 99 -> position -1
15 deleted
5 deleted
99 not found
list: 10 -> 20 -> 30 -> NULL
- Three cases, and all three must work — the value is not there at all, the value is in the head, the value is somewhere after it. The
prev == NULL test is what separates the second from the third.
free(p) comes last. Unlink first, then free — touching p->next after free(p) is a dangling read, and it is the commonest way this function is written wrong.
- Searching is a walk, not a jump. There is no
list[4] — finding position 4 means following four pointers. This is the price a list pays for its cheap inserts.
💡Binary search is impossible here, even on a sorted list. It needs to jump straight to the middle, and a linked list has no middle it can reach without walking. A structure's shape decides which algorithms it can host — which is why the choice of structure matters more than the cleverness of the code inside it.
Walking the chain, and taking it down
Save the next pointer before you free the node
void display(const struct Node *head)
{
printf("list: ");
for (const struct Node *p = head; p != NULL; p = p->next)
printf("%d -> ", p->data);
printf("NULL\n");
}
void free_all(struct Node *head)
{
struct Node *next;
while (head != NULL) {
next = head->next; /* save it BEFORE freeing */
free(head);
head = next;
}
}
broken — reads freed memory
while (head != NULL) {
free(head);
head = head->next; /* head is already gone */
}
The broken version often appears to work. free does not wipe the memory or change your pointer — it just marks the block reusable. The old next value is usually still sitting there, so the loop limps along until the allocator reuses that block and the value turns to rubbish. It is undefined behaviour that passes its first test, which is the worst kind.
The traversal idiom, once and for all. for (p = head; p != NULL; p = p->next). Every list function on these slides is that loop with a different body: printing, counting, comparing, summing. Learn the loop and the rest is detail.
const struct Node * on the read-only ones. display and search promise not to modify the list, and the compiler holds them to it. free_all takes a plain pointer, because destroying the list is precisely its job.
💡One malloc per node means one free per node. A 1,000-node list needs 1,000 calls to free, and the only way to reach node 1,000 is through node 999 — so losing head halfway through leaks everything after it. free_all is not tidying up; it is the only path those nodes will ever have back.
Day 19 · Module 11 · Data structures
Today we cover
- Stacks — last in, first out — built two ways, on an array and on a linked list
push, pop, peek, and the two errors every stack must refuse: overflow and underflow
- A real use: checking whether the brackets in an expression are balanced
- Queues — first in, first out — and why a plain array queue wastes its own space
- The circular queue, where
rear wraps back round to index 0
By the end of today you can implement a stack and a queue from scratch, and explain which one a problem needs by asking a single question: which end does the next item have to come out of?
Last in, first out
One index, called top, does everything
stackarr.c · the structure
#define MAX 5
struct Stack {
int items[MAX];
int top; /* index of the top; -1 when empty */
};
void init(struct Stack *s) { s->top = -1; }
int is_empty(const struct Stack *s) { return s->top == -1; }
int is_full(const struct Stack *s) { return s->top == MAX - 1; }
stackarr.c · push and pop
void push(struct Stack *s, int value)
{
if (is_full(s)) {
printf("overflow - cannot push %d\n", value);
return;
}
s->items[++s->top] = value;
}
int pop(struct Stack *s)
{
if (is_empty(s)) {
printf("underflow - nothing to pop\n");
return -1;
}
return s->items[s->top--];
}
$ ./stackarr
pushed 10 (top = 0)
pushed 20 (top = 1)
pushed 30 (top = 2)
peek = 30
popped = 30
popped = 20
popped = 10
underflow - nothing to pop
- LIFO — last in, first out. 10, 20, 30 went in; 30, 20, 10 came out. A stack of plates: you take the one you put down last.
++s->top and s->top-- are doing real work. Pre-increment on the way in (move up, then store), post-decrement on the way out (read, then move down). Day 4's operators, earning their keep.
- Both errors must be refused, not crashed on. Pushing onto a full array would write past its end; popping an empty one would read index −1. Guarding these two is most of what a correct stack is.
💡You have been standing on one since Day 8. The call stack is a stack: each call pushes a frame, each return pops the most recent one. That is exactly why recursion unwinds in reverse order, and why an endless recursion is called a stack overflow — the same word this slide's guard prints.
The same rules, no ceiling
The head is the top
/* push = insert at front, pop = delete from front,
so both are one step and there is no MAX. */
struct Node *push(struct Node *top, int value)
{
struct Node *n = malloc(sizeof(struct Node));
if (n == NULL) exit(1);
n->data = value;
n->next = top;
return n;
}
struct Node *pop(struct Node *top, int *out)
{
if (top == NULL) {
printf("underflow - nothing to pop\n");
*out = -1;
return NULL;
}
struct Node *next = top->next;
*out = top->data;
free(top);
return next;
}
$ ./stacklist
pushed 10
pushed 20
pushed 30
popped = 30
popped = 20
popped = 10
underflow - nothing to pop
Identical output, different machinery. The array version refused a sixth push; this one never will, until the heap itself runs out. Same interface, same LIFO behaviour, different trade.
| Array stack | Linked stack |
| Capacity | Fixed MAX | Limited only by the heap |
| Overflow | Must be guarded | Cannot happen |
| Memory per item | Just the value | Value + 8-byte link |
| Speed | Faster — no allocation | A malloc per push |
| Use when | You know the maximum | You do not |
💡int *out is call by reference, again. pop already returns the new top, so the popped value needs a second way out — an address the caller supplies. Module 7's technique, used because a C function returns exactly one thing and this one has two answers.
A stack doing real work
Push what opens, pop what closes
int balanced(const char *s)
{
char stack[MAX];
int top = -1;
for (int i = 0; s[i] != '\0'; i++) {
char c = s[i];
if (c == '(' || c == '[' || c == '{') {
stack[++top] = c; /* push */
} else if (c == ')' || c == ']' || c == '}') {
if (top == -1) /* closed, nothing open */
return 0;
char open = stack[top--]; /* pop */
if (!matches(open, c))
return 0;
}
}
return top == -1; /* everything opened was closed */
}
$ ./paren · balanced
Expression: {[()]}
push { (depth 1)
push [ (depth 2)
push ( (depth 3)
pop ( against )
pop [ against ]
pop { against }
balanced
$ ./paren · not balanced
Expression: {[(])}
push { (depth 1)
push [ (depth 2)
push ( (depth 3)
pop ( against ]
NOT balanced
Why a stack is the right structure. Brackets must close in the reverse of the order they opened — which is precisely LIFO. The innermost open bracket is always the one on top, so it is always the one the next closer must match. No other structure gives you that for free.
Three ways it can fail, all handled. A closer with nothing open (top == -1); a closer against the wrong opener (!matches); and openers left over at the end (top != -1), which is what catches "((()". Miss the last one and the function calls an unfinished expression balanced.
💡This is a real compiler doing a real job. The error "expected } at end of input" that GCC has shown you all course comes from exactly this algorithm, run over your source file. Editors highlight matching brackets the same way. It is also the standard interview question — and now you can say why the stack is not a clever trick but the only structure that fits the problem.
First in, first out
Two indices, and a % that makes the array bend round
#define MAX 5
struct Queue {
int items[MAX];
int front, rear, count;
};
void init(struct Queue *q)
{ q->front = 0; q->rear = -1; q->count = 0; }
void enqueue(struct Queue *q, int value)
{
if (q->count == MAX) {
printf("full - cannot add %d\n", value);
return;
}
q->rear = (q->rear + 1) % MAX; /* wrap around */
q->items[q->rear] = value;
q->count++;
}
int dequeue(struct Queue *q)
{
if (q->count == 0) {
printf("empty - nothing to remove\n");
return -1;
}
int value = q->items[q->front];
q->front = (q->front + 1) % MAX; /* wrap around */
q->count--;
return value;
}
$ ./cqueue
added 10 front=0 rear=0 count=1
added 20 front=0 rear=1 count=2
added 30 front=0 rear=2 count=3
added 40 front=0 rear=3 count=4
added 50 front=0 rear=4 count=5
full - cannot add 60
removed 10 front=1 rear=4 count=4
removed 20 front=2 rear=4 count=3
added 60 front=2 rear=0 count=4
added 70 front=2 rear=1 count=5
Watch rear in the last two lines. It goes 4, then 0, then 1 — wrapping into the slots that 10 and 20 vacated. A plain, non-circular queue would have reported "full" there, with two free slots sitting unused at the front. That waste is the entire reason the circular version exists.
Why keep a count? Because once the indices wrap, front == rear is true both when the queue is empty and when it is full — the two states become indistinguishable. Counting the items removes the ambiguity. The alternative is to deliberately waste one slot so the two cases never collide.
💡FIFO is fairness. A printer queue, keystrokes waiting to be processed, customers at a counter, packets on a network card — anywhere "whoever asked first gets served first" is the rule, the structure underneath is a queue. A stack would serve the most recent arrival first, which is exactly the wrong behaviour for all of them.
Day 20 · Module 11 · Data structures
Today we cover
- The doubly linked list — one extra pointer, and the chain can be walked both ways
- Big-O notation, in words: what O(1) and O(n) actually promise you
- Every structure on the course, side by side, with the cost of each operation
- Choosing the right one for a problem — the skill the whole module was for
- The phone book practical, which puts the lot together
By the end of today you can look at a problem and say which structure it wants, and defend the answer in terms of what each operation costs rather than what you happen to have used before.
One more pointer
A chain you can walk backwards
dll.c · the node and the link
struct Node {
int data;
struct Node *prev; /* the only new field */
struct Node *next;
};
struct Node *insert_end(struct Node *head, int value)
{
struct Node *n = make_node(value);
if (head == NULL)
return n;
struct Node *p = head;
while (p->next != NULL)
p = p->next;
p->next = n;
n->prev = p; /* the backward link */
return head;
}
dll.c · walking backwards
const struct Node *p = head;
while (p->next != NULL) /* find the last node */
p = p->next;
printf("backward: ");
for (; p != NULL; p = p->prev)
printf("%d ", p->data);
$ ./dll
forward : 10 20 30 40
backward: 40 30 20 10
The chain drawn out.
NULL <-+ +-----+ +-----+
| | | | |
+--+--+ +-+---+-++ +--+--+--+
| 10 |->| 20 |-> | 30 | NULL
+-----+<-+-----+<- +-----+
- Deleting gets easier. In a singly linked list you must remember the previous node while walking. Here the node carries its own
prev, so any node can unlink itself — the reason editors and browsers use one for undo and back/forward.
- Every operation now maintains two links. Forget to set
prev on an insert and the forward walk still looks perfect while the backward walk breaks — a bug that hides until someone goes back.
- The cost: 8 more bytes per node, and twice the pointer bookkeeping in every function.
💡The other variant worth knowing. In a circular list the last node's next points back at the head instead of NULL, so a walk never ends — useful for round-robin turns and playlists on repeat. Combine the two and you get a circular doubly linked list, which is what many real schedulers use.
What "efficient" means
How the cost grows when the data gets bigger
| Operation | Cost | Why |
Array — read a[i] | O(1) | One multiplication and one memory read, whatever i is |
| Array — insert at front | O(n) | Every later element shifts up one place |
| Array — linear search | O(n) | May have to look at every element |
| Sorted array — binary search | O(log n) | Halves what is left with each comparison |
| List — insert at front | O(1) | Make a node, point it at the old head |
| List — insert at end | O(n) | Must walk the whole chain to find the last node |
| List — search | O(n) | No indexing; follow the links one at a time |
| Stack — push / pop | O(1) | Only ever touches the top |
| Queue — enqueue / dequeue | O(1) | Only ever touches the two ends |
| Bubble sort | O(n²) | A pass for every element, and a comparison for every element in it |
Read O(n) as "grows in step with the data". Double the items and it takes about twice as long. O(1) means the time does not depend on the size at all — one item or a million, the same work. O(n²) means doubling the data quadruples the time, which is why bubble sort is fine for 20 marks and hopeless for 20,000 records.
| n | O(1) | O(log n) | O(n) | O(n2) |
| 10 | 1 | 3 | 10 | 100 |
| 1,000 | 1 | 10 | 1,000 | 1,000,000 |
| 1,000,000 | 1 | 20 | 1,000,000 | 1012 |
- Constants are deliberately ignored. O(n) and O(3n) are both written O(n), because the shape of the growth is what decides whether a program survives a bigger input — not whether it is three times quicker on today's.
- It is about the worst case, unless someone says otherwise. Linear search might find the value first try; O(n) is the promise that it will never be worse than looking at everything.
💡Small n makes all of this irrelevant. For twenty records, use whatever is clearest to read — the difference is microseconds. Big-O earns its keep at the point where the data grows and a program that was instant last year now takes a minute. Knowing which line of the table you are standing on tells you whether that will happen.
Choosing
Four structures, and the question each one answers
| Array | Linked list | Stack | Queue |
| Size | Fixed | Grows freely | Either | Either |
| Reach item i | O(1) | O(n) | Top only | Front only |
| Insert front | O(n) | O(1) | — | — |
| Insert end | O(1) if room | O(n) | — | O(1) |
| Remove | O(n) | O(1) once found | O(1) top | O(1) front |
| Order out | Any | Any | LIFO | FIFO |
| Memory overhead | None | A pointer per node | Depends | Depends |
Stacks and queues are not rivals of the other two — they are restrictions. Each is built on an array or a list, and then deliberately refuses access to everything but one or two ends. That refusal is the feature: a structure that cannot be touched in the middle cannot be corrupted in the middle.
| If the problem says… | Reach for |
| "Marks of 50 students, fixed" | Array |
| "Jump straight to record 900" | Array |
| "However many the user enters" | Linked list |
| "Insert and delete constantly" | Linked list |
| "Undo the last action" | Stack |
| "Check matching brackets" | Stack |
| "Reverse something" | Stack |
| "Serve in the order they arrived" | Queue |
| "Print jobs, requests, buffering" | Queue |
| "Go back and forward" | Doubly linked list |
💡The question to ask, every time: how will this data be used? Not how it will be stored — how it will be reached. Mostly read by position, rarely changed? Array. Constantly growing and shrinking? List. Only ever the most recent item? Stack. Only ever the oldest? Queue. Get that answer right and the code writes itself; get it wrong and you spend the rest of the project fighting the shape you chose.
Module 11 practical
A phone book that stays in alphabetical order
struct Contact {
char name[30];
char phone[15];
struct Contact *next;
};
phonebook.c · 2/6 · build the node
struct Contact *add_contact(struct Contact *head,
const char *name, const char *phone)
{
struct Contact *n = malloc(sizeof(struct Contact));
if (n == NULL) return head;
snprintf(n->name, sizeof(n->name), "%s", name);
snprintf(n->phone, sizeof(n->phone), "%s", phone);
n->next = NULL;
phonebook.c · 3/6 · link it in order
/* goes before the head? */
if (head == NULL || strcmp(name, head->name) < 0) {
n->next = head;
return n;
}
/* walk until the next name is bigger */
struct Contact *p = head;
while (p->next != NULL
&& strcmp(name, p->next->name) > 0)
p = p->next;
n->next = p->next;
p->next = n;
return head;
}
phonebook.c · 4/6 · search
const struct Contact *find_contact(
const struct Contact *head, const char *name)
{
for (const struct Contact *p = head; p != NULL; p = p->next)
if (strcmp(p->name, name) == 0)
return p;
return NULL;
}
phonebook.c · 5/6 · delete
struct Contact *delete_contact(struct Contact *head,
const char *name)
{
struct Contact *p = head, *prev = NULL;
while (p != NULL && strcmp(p->name, name) != 0) {
prev = p;
p = p->next;
}
if (p == NULL) {
printf("%s not found\n", name);
return head;
}
if (prev == NULL) head = p->next;
else prev->next = p->next;
free(p);
return head;
}
phonebook.c · 6/6 · cleanup
void free_all(struct Contact *head)
{
struct Contact *next;
while (head != NULL) {
next = head->next;
free(head);
head = next;
}
}
/* main runs the menu, then calls free_all(head)
once before returning - every node released. */
$ ./phonebook
1. Add 2. Search 3. Delete 4. List 5. Exit
Enter choice: 1
Name: Rahul Verma
Phone: 98765 43210
Added Rahul Verma
1. Add 2. Search 3. Delete 4. List 5. Exit
Enter choice: 1
Name: Asha Menon
Phone: 91234 56789
Added Asha Menon
1. Add 2. Search 3. Delete 4. List 5. Exit
Enter choice: 1
Name: Vikram Rao
Phone: 99887 66554
Added Vikram Rao
1. Add 2. Search 3. Delete 4. List 5. Exit
Enter choice: 4
NAME PHONE
Asha Menon 91234 56789
Rahul Verma 98765 43210
Vikram Rao 99887 66554
1. Add 2. Search 3. Delete 4. List 5. Exit
Enter choice: 2
Name: Asha Menon
Asha Menon -> 91234 56789
1. Add 2. Search 3. Delete 4. List 5. Exit
Enter choice: 3
Name: Rahul Verma
Deleted Rahul Verma
1. Add 2. Search 3. Delete 4. List 5. Exit
Enter choice: 4
NAME PHONE
Asha Menon 91234 56789
Vikram Rao 99887 66554
1. Add 2. Search 3. Delete 4. List 5. Exit
Enter choice: 5
Goodbye.
- Rahul was entered first and still came out second. The list is never sorted, because it is never unsorted — each contact is linked into its alphabetical place as it arrives. Doing the same with an array would mean shifting every later element on every insert.
strcmp decides the order. Module 6's function, returning a negative number when the first string sorts earlier — which is exactly the test insert_at needed.
- Every node is freed.
delete_contact frees the one it unlinks, and free_all takes down whatever is left before main returns.
💡This is the whole course in one file. A struct for the record (Module 9), a pointer to its own type for the chain (Module 11), malloc and free for the nodes (Module 8), -> on every line (Module 7), strcmp and snprintf for the strings (Module 6), functions with clear jobs (Module 5) and a menu loop (Module 4). Add Module 10's file saving and it is a program someone could actually use.
★
Capstone — Student Record System
One program, three files, and every single thing this course taught. Records held in a linked list, saved to a binary file, and brought back the next time it runs.
| Feature | What it uses | From |
| A student record | struct, typedef, array member | Module 9 |
| Any number of students | malloc, free, linked list | Modules 8, 11 |
| Add, delete, search | Pointers, ->, strcmp | Modules 6, 7 |
| Sort by total | Relinking nodes, comparison | Modules 6, 11 |
| Totals, averages, grades | Loops, if-chains, float | Modules 2, 4 |
| Saving and loading | fopen, fwrite, fread | Module 10 |
| The menu | do-while, switch | Module 4 |
| Three files, one program | Header, include guard, prototypes | Modules 5, 10 |
The contract
One header every file agrees on
#ifndef STUDENT_H
#define STUDENT_H
#define MAX_NAME 40
#define SUBJECTS 5
#define DATA_FILE "students.dat"
typedef struct Student {
int roll;
char name[MAX_NAME];
float marks[SUBJECTS];
struct Student *next; /* the tag is needed here */
} Student;
student.h · the prototypes
Student *add_student(Student *head, int roll,
const char *name, const float *marks);
Student *delete_student(Student *head, int roll);
const Student *find_by_roll(const Student *head, int roll);
const Student *find_by_name(const Student *head, const char *name);
Student *sort_by_total(Student *head);
void free_all(Student *head);
float total_of(const Student *s);
float average_of(const Student *s);
char grade_of(const Student *s);
void print_report(const Student *head);
int save_to_file(const Student *head);
Student *load_from_file(void);
#endif /* STUDENT_H */
- The header holds no code — only the type, the three constants and thirteen promises.
student.c keeps those promises; main.c relies on them and never needs to see how.
- The
struct Student tag is kept, even though the typedef gives the short name Student. Inside the braces that short name does not exist yet, so the self-link must be written struct Student *next; — the exact case Module 9 flagged.
const marks the read-only ones. find_by_roll, print_report and save_to_file promise not to change the list, and the compiler enforces it. delete_student and free_all take plain pointers, because changing is their job.
- Four functions return
Student * — each can change which node is the head, and C cannot change the caller's variable for them.
Build it with:
$ gcc -Wall -Wextra -std=c11 main.c student.c -o srms
$ ./srms
💡Read the header first, always. Handed an unfamiliar C project, the headers tell you what exists and what it is called before you read a single line of implementation. That is why this file is the first slide of the capstone and not the last.
student.c · the list
Add, delete, find, sort
student.c · add_student · fill the node
Student *add_student(Student *head, int roll,
const char *name, const float *marks)
{
Student *n = malloc(sizeof(Student));
if (n == NULL) {
printf("Out of memory.\n");
return head;
}
n->roll = roll;
snprintf(n->name, sizeof(n->name), "%s", name);
for (int i = 0; i < SUBJECTS; i++)
n->marks[i] = marks[i];
n->next = NULL;
student.c · add_student · link it in
/* keep the list sorted by roll number */
if (head == NULL || roll < head->roll) {
n->next = head;
return n;
}
Student *p = head;
while (p->next != NULL && p->next->roll < roll)
p = p->next;
n->next = p->next;
p->next = n;
return head;
}
student.c · sort_by_total · detach
/* Insertion sort by RELINKING nodes - no data is
copied, only pointers are rearranged. */
Student *sort_by_total(Student *head)
{
Student *sorted = NULL;
while (head != NULL) {
Student *node = head;
head = head->next; /* detach the first */
node->next = NULL;
student.c · sort_by_total · place it
if (sorted == NULL
|| total_of(node) > total_of(sorted)) {
node->next = sorted;
sorted = node;
} else {
Student *p = sorted;
while (p->next != NULL
&& total_of(p->next) >= total_of(node))
p = p->next;
node->next = p->next;
p->next = node;
}
}
return sorted;
}
Two different sorted orders, on purpose. add_student keeps the list in roll-number order as records arrive, so the report reads like a register. sort_by_total rebuilds it in rank order, highest first. Both are the same technique — walk until the next node should come after the new one, then splice — with a different comparison.
Sorting moves pointers, not records. Nothing is copied and nothing is reallocated; the nodes stay exactly where malloc put them and only the next fields change. On an array of 64-byte records a sort has to move the records themselves — which is the trade Module 11's comparison table was describing.
💡delete_student, find_by_roll and find_by_name are the Module 11 functions unchanged — a trailing prev pointer for the delete, a straight traversal for each search, strcmp for the name. Nothing in this capstone is a technique you have not already written once.
student.c · persistence
Save the record, never the pointer
student.c · what goes on disk
/* The record WITHOUT the next pointer. An address is
meaningless in the next run, so it is never saved. */
struct Record {
int roll;
char name[MAX_NAME];
float marks[SUBJECTS];
};
FILE *fp = fopen(DATA_FILE, "wb");
if (fp == NULL) { perror(DATA_FILE); return 0; }
int n = 0;
for (const Student *p = head; p != NULL; p = p->next) {
struct Record r;
r.roll = p->roll;
snprintf(r.name, sizeof(r.name), "%s", p->name);
for (int i = 0; i < SUBJECTS; i++)
r.marks[i] = p->marks[i];
if (fwrite(&r, sizeof r, 1, fp) != 1) {
perror(DATA_FILE);
fclose(fp);
return 0;
}
n++;
}
fclose(fp);
student.c · load_from_file
FILE *fp = fopen(DATA_FILE, "rb");
if (fp == NULL) /* no file yet is not an error */
return NULL;
Student *head = NULL;
struct Record r;
int n = 0;
while (fread(&r, sizeof r, 1, fp) == 1) {
head = add_student(head, r.roll, r.name, r.marks);
n++;
}
fclose(fp);
return head;
This is the one idea worth taking away. A linked list cannot be written to a file as it stands, because every node holds an address — and an address from this run means nothing in the next one. So the list is flattened on the way out: each node becomes a plain struct Record, and the links are thrown away. On the way back in, add_student rebuilds them from scratch. The structure lives in memory; only the data goes to disk.
Two records made 128 bytes. 4 for the int, 40 for the name, 20 for five floats — 64 exactly, with no padding, because each member already sits on a 4-byte boundary. Module 9's padding slide, showing up in a real file size.
💡fread in the loop condition is the correct idiom — while (fread(&r, sizeof r, 1, fp) == 1) tests the read itself, so it stops on the last real record rather than processing a stale one. A missing file simply returns NULL: the first run has nothing to load, and that is not a failure.
main.c · the whole thing running
Load, loop, save, free
int main(void)
{
Student *head = load_from_file(); /* pick up where
we left off */
int choice, roll;
char name[MAX_NAME];
do {
printf("\n=== Student Record System ===\n");
printf("1. Add 2. Delete 3. Find by roll "
"4. Find by name\n");
printf("5. Sort by total 6. Report "
"7. Save 8. Exit\n");
printf("Enter choice: ");
if (scanf("%d", &choice) != 1)
break;
main.c · dispatch, save, free
switch (choice) {
case 1: read_and_add(&head); break;
case 2: /* ... delete_student ... */ break;
/* 3-7: find, sort, report, save */
case 8:
save_to_file(head); /* save on the way out */
printf("Goodbye.\n");
break;
default: printf("Invalid choice.\n");
}
} while (choice != 8);
free_all(head); /* every node released */
return 0;
}
$ ./srms · first run
=== Student Record System ===
1. Add 2. Delete 3. Find by roll 4. Find by name
5. Sort by total 6. Report 7. Save 8. Exit
Enter choice: 1
Roll: 103
Name: Fatima Khan
Marks for 5 subjects: 95 88 92 90 97
Added Fatima Khan.
(101 Asha Menon and 102 Rahul Verma added the same way)
Enter choice: 6
ROLL NAME TOTAL AVG GRADE
-------------------------------------------
101 Asha Menon 413.0 82.6 B
102 Rahul Verma 310.0 62.0 C
103 Fatima Khan 462.0 92.4 A
Enter choice: 5
Sorted by total, highest first.
Enter choice: 6
ROLL NAME TOTAL AVG GRADE
-------------------------------------------
103 Fatima Khan 462.0 92.4 A
101 Asha Menon 413.0 82.6 B
102 Rahul Verma 310.0 62.0 C
Enter choice: 2
Roll: 102
Deleted Rahul Verma (roll 102).
Enter choice: 8
Saved 2 record(s) to students.dat
Goodbye.
$ ./srms · second run — the point of the whole thing
Loaded 2 record(s) from students.dat
Enter choice: 6
ROLL NAME TOTAL AVG GRADE
-------------------------------------------
101 Asha Menon 413.0 82.6 B
103 Fatima Khan 462.0 92.4 A
- The second run is the capstone. The program was closed, the process ended, every
malloced byte was handed back — and the records came back anyway, in roll order, rebuilt into a fresh linked list from 128 bytes on disk.
- Note what the second run does not show. The sort from the first run is gone, because sorting rearranged pointers and the file only ever held records.
add_student rebuilt the list in roll order on load. That is not a bug — it is the file layer doing exactly what it promised.
read_and_add(&head) takes the address of the head pointer — a Student **, so the helper can update main's own variable. Module 7's pointer-to-pointer, in the one place it earns its keep.
💡Two lines carry the whole design. load_from_file() on the first line of main and free_all(head) on the last. Between them the program works entirely in memory and never thinks about the disk again — which is why the list code and the file code never got tangled together.
Your turn
Ten ways to make it yours
| Extension | What it exercises |
Subject names, not just marks[5] | Arrays of strings, a second struct |
| Edit an existing record | Search, then write through the pointer |
| Search by partial name | strstr instead of strcmp |
| Class topper and subject topper | Traversal with a running best |
| Pass/fail count and class average | One pass, several accumulators |
| Export the report as CSV | fprintf to a text file |
| Refuse a duplicate roll number | find_by_roll before add_student |
| Undo the last delete | A stack of removed records |
| A doubly linked list | A prev pointer, both walks |
Split student.c into list + report + file | Three headers, one build command |
Start with the duplicate-roll check. It is four lines, it uses a function that already exists, and it turns the program from a demonstration into something that defends itself against its user. Then do the CSV export — opening your own report in Excel is a disproportionately satisfying five minutes.
Then try the hard one. Splitting student.c into three files with three headers is not more code — it is the same code, reorganised, and it forces you to decide what each part is allowed to know about the others. That decision is most of what software design is, and it is the last thing this syllabus can teach you with a compiler.
Keep it. This program is a legitimate answer to "show me something you have written" — it has a data structure, a file format, a clean header and no leaks. Put it in a folder with a README saying how to build it, and it is the first entry in a portfolio.
💡Every extension above is deliberately reachable with what you already know — not one of them needs a feature this course did not cover. That is the real measure of the twenty days: the syllabus is finished, and the problems you can now attack are not.
Everything, on one slide
The whole language, as far as this course goes
#include <stdio.h>
int main(void) { return 0; }
int i; float f; double d; char c; long l;
unsigned u; const int MAX = 100;
printf("%d %f %c %s %p %zu\n", i, f, c, s, p, n);
scanf("%d", &i); /* & on everything but a string */
scanf(" %39[^\n]", name); /* a whole line with spaces */
sizeof(int) /* bytes */
(float)a / b /* cast before dividing two ints */
a % b /* remainder - ints only */
a++ ++a += -= *= /=
== != < > <= >= && || !
cond ? yes : no
if (a > b) { } else if (a == b) { } else { }
switch (n) { case 1: ...; break; default: ...; }
while (c) { } do { } while (c);
for (int i = 0; i < n; i++) { }
break; continue;
int add(int a, int b) { return a + b; }
void show(const int *a, int n); /* prototype */
static int counter; /* keeps its value */
extern int shared; /* defined elsewhere */
int fact(int n) { /* recursion */
if (n <= 1) return 1; /* base case */
return n * fact(n - 1);
}
arrays, strings, pointers & memory
int a[5] = {1, 2, 3}; /* rest are 0 */
int m[3][4]; /* m[row][col] */
char s[20] = "hello"; /* '\0' at the end */
strlen(s) strcpy(d,s) strcmp(a,b) strcat(d,s)
int *p = &n; *p = 5; p++; /* moves sizeof(int) */
a[i] == *(a + i)
void swap(int *a, int *b); swap(&x, &y);
int *v = malloc(n * sizeof(int));
if (v == NULL) return 1;
v = NULL; free(v); /* free FIRST, then NULL */
calloc(n, size) realloc(p, size)
struct Student { int roll; float marks; };
struct Student s = {101, 87.5f};
s.roll; p->roll; /* p->x is (*p).x */
typedef struct { ... } Student;
union U { int i; float f; }; /* shared bytes */
enum Day { MON = 1, TUE, WED };
FILE *fp = fopen("f.txt", "r"); /* r w a rb wb ab */
if (fp == NULL) { perror("f.txt"); return 1; }
fprintf(fp, ...); fgets(line, sizeof line, fp);
fwrite(&r, sizeof r, 1, fp); fread(&r, sizeof r, 1, fp);
fseek(fp, n, SEEK_SET); ftell(fp); fclose(fp);
struct Node { int data; struct Node *next; };
for (p = head; p != NULL; p = p->next)
Define the following terms
Every word a paper can ask you to define
| Term | One-sentence definition |
| Token | The smallest meaningful unit of a C program — keyword, identifier, constant, string literal, operator or punctuator |
| Identifier | A programmer-chosen name for a variable, function or type |
| Keyword | A word reserved by the language itself, such as int or while, that cannot be used as an identifier |
| Literal | A fixed value written directly in the source, such as 42 or "hi" |
| Constant | A value that cannot change after it is set, declared with const or #define |
| Variable | A named piece of memory whose value can change while the program runs |
| Data type | What kind of value a variable holds and how many bytes it occupies |
| Operator | A symbol that performs an operation on one or more operands |
| Expression | Any combination of values, variables and operators that produces a value |
| Statement | A complete instruction, ended by a semicolon |
| Compiler | The program that translates C source into machine code |
| Preprocessor | The first stage, which obeys the # directives by substituting text before compilation |
| Linker | The stage that joins object files and libraries into one executable |
| Object file | The compiled but not yet linked output of one source file |
| Header file | A .h file of declarations, included by the sources that need them |
| Function prototype | A declaration giving a function's name, return type and parameters, but no body |
| Parameter | The variable named in a function's definition |
| Argument | The actual value passed to a function when it is called |
| Call by value | Passing a copy, so the function cannot change the caller's variable |
| Call by reference | Passing an address, so the function can change what it points at |
| Scope | The region of the program in which a name is visible |
| Storage class | How long a variable lives and where it is visible — auto, static, extern, register |
| Recursion | A function calling itself to solve a smaller version of the same problem |
| Base case | The condition at which a recursive function stops calling itself |
| Array | A fixed-size, contiguous collection of elements of one type |
| Index | The position of an element in an array, counted from 0 |
| Term | One-sentence definition |
| String | A character array ending in the null character |
| Null terminator | The '\0' byte that marks the end of a C string |
| Pointer | A variable that stores the memory address of another variable |
| Dereference | Following a pointer with * to reach the value it points at |
| Pointer arithmetic | Adding to or subtracting from a pointer, which moves it by whole elements, not bytes |
| Dangling pointer | A pointer still holding the address of memory that has been freed or has gone out of scope |
| Memory leak | Heap memory that was allocated but can no longer be freed, because nothing points at it any more |
| Stack (memory) | The region holding local variables and call frames, freed automatically on return |
| Heap | The region from which malloc allocates, freed only when you call free |
| Structure | A user-defined type grouping several members, each with its own storage |
| Union | Like a structure, but all members share one block of memory — only the last written is valid |
| Enumeration | A user-defined type naming a fixed set of integer constants |
| typedef | A new name for an existing type — it creates no new type |
| Padding | Bytes the compiler inserts between members so each is correctly aligned |
| File pointer | A FILE * handle returned by fopen, holding the position and state of an open file |
| Stream | The sequence of bytes flowing to or from a file or device |
| Macro | A name defined with #define that the preprocessor replaces with its text |
| Include guard | The #ifndef / #define / #endif pair that stops a header being inserted twice |
| Data structure | A way of organising data that makes particular operations on it cheap |
| Node | One element of a linked structure, holding data and a pointer to the next |
| Linked list | A chain of separately allocated nodes, each pointing to the next, ending at NULL |
| LIFO | Last in, first out — the order a stack removes items in |
| FIFO | First in, first out — the order a queue removes items in |
| Traversal | Visiting every element of a structure exactly once |
| Time complexity | How the work an operation does grows as the amount of data grows, written in Big-O |
Twenty days, done
Everything you have actually built
| Day | You can now… | |
| 1 | Compile and run a C program, and explain every line | ✓ |
| 2 | Write an algorithm, name the token kinds, read an error | ✓ |
| 3 | Choose a data type and use the right format specifier | ✓ |
| 4 | Use every operator, and cast before dividing two ints | ✓ |
| 5 | Read input safely and format output into columns | ✓ |
| 6 | Branch with if chains and switch | ✓ |
| 7 | Write any loop, nest them, and print patterns | ✓ |
| 8 | Split a program into functions with prototypes | ✓ |
| 9 | Write a recursive function with a correct base case | ✓ |
| 10 | Search and sort an array three ways | ✓ |
| Day | You can now… | |
| 11 | Work with 2D arrays and handle strings correctly | ✓ |
| 12 | Declare, dereference and do arithmetic on pointers | ✓ |
| 13 | Use call by reference, and pointers to functions | ✓ |
| 14 | Allocate at run time and free without leaking | ✓ |
| 15 | Design a record with struct and hold many of them | ✓ |
| 16 | Use typedef, union, enum, and predict sizeof | ✓ |
| 17 | Save data to a file and load it back next run | ✓ |
| 18 | Build, search and free a linked list | ✓ |
| 19 | Implement a stack and a circular queue | ✓ |
| 20 | Choose the right structure and justify it in Big-O | ✓ |
💡Plus what the course made you do, not just read: 11 practicals, 6 practice sets worth 48 marks each, 6 timed tests, and a three-file capstone that saves its data and leaks nothing. Every program on every slide was compiled with -Wall -Wextra and run before it was shown to you.
After the syllabus
Four doors, and this course is the key to all of them
Embedded systems & firmware. Microcontrollers, Arduino, automotive and medical devices — C is still the language, often the only one. Everything you learned about pointers, sizeof, memory and bit operations is the daily work, and the discipline about malloc matters more, not less, on a chip with 2 KB of RAM.
Operating systems & systems programming. Linux, Windows kernels, databases, network stacks and the Python interpreter itself are written in C. The next step is processes, threads, sockets and system calls — all of which are C functions taking the pointers and structs you now read fluently.
Data structures & competitive programming. Module 11 is the doorway: trees, graphs, hashing, heaps and the algorithms over them are the DSA paper in every syllabus and the whole of the technical interview. You have the two things that make them learnable — pointers and dynamic memory.
C++, Rust and the rest. C++ is C with objects and templates bolted on, and it assumes you know this. Java, C#, JavaScript, Go and Python all borrow C's syntax, so their loops and conditions will already read as familiar. Rust exists specifically to solve the dangling-pointer and memory-leak problems you met in Module 8 — and nothing it does makes sense until you have been bitten by them in C.
💡C is the language you learn once and read for ever. It has barely changed in fifty years; the hello.c from Day 1 would have compiled in 1990 and will compile in 2050. That stability is why it is the first language on so many syllabi — and why the twenty days behind you do not expire.
BrainBit Infotech Teaching · C Programming · Complete
You’re ready.
You started twenty days ago not knowing what a compiler was. You have written a bill calculator, a mark sheet, a menu calculator, a matrix multiplier, a bank, a phone book and a record system that remembers its data between runs — in the language the operating system under your fingers is written in.
20days
11modules
11practicals
1capstone
The only thing left is to keep writing it. Open the capstone, pick one line from “Extend it”, and make the program a little more yours than it was yesterday. That is the whole method, and it does not change from here.
BrainBit Infotech Teaching · C Programming · Final test
Now prove it.
Fifty questions across all eleven modules — twenty-five that ask you to read code and say what it prints, twenty-five that ask you why. Forty minutes. No notes, no compiler.
50questions
40minutes
50%to pass
1certificate
Score 50% or more and you earn a certificate from BrainBit Infotech Private Limited, with a number your college or employer can check for themselves. Sit it at brainbit-teaching.com/tests/?test=c-final.