Module 3.2

Function Parameters

Master how C functions receive, process, and manipulate data with confidence. This lesson covers parameter passing fundamentals, pass-by-value behavior, simulating pass-by-reference using pointers, handling arrays as function arguments, and using qualifiers like const and restrict for safer and more efficient code. You’ll also learn best practices to avoid common pointer-related errors and write robust, maintainable C programs.

30 min read
Beginner
Hands-on Examples
What You'll Learn
  • Pass by value
  • Pass by reference (pointers)
  • Passing arrays to functions
  • Parameter modifiers (const, etc.)
  • Advanced parameter patterns
Contents
01

Pass by Value

In C, pass by value is the default mechanism for passing arguments to functions. When a variable is passed, the function receives a copy of that variable. Any modification inside the function affects only the copy—not the original value. Understanding this concept is essential for writing safe, predictable, and bug-free C programs.

Definition: Pass by value means the function receives a copy of the argument's value, not the original variable itself. Think of it like giving someone a photocopy of a document - they can write on the photocopy all they want, but your original document stays untouched. The copy is created on the stack when the function is called and is automatically destroyed when the function returns. This is C's default behavior for all basic data types (int, float, char, etc.).

Syntax:

return_type function_name(data_type parameter_name) {
    // parameter_name is a COPY of the argument
    // modifications here do NOT affect the original
}

// Calling the function
function_name(variable);  // passes a copy of 'variable'

Basic Example

In the following example, the variable value is passed to the function increment(). The function modifies its local copy, but the original variable in main() remains unchanged.

#include <stdio.h>

void increment(int num) {
    num = num + 1;
    printf("Inside function: %d\n", num);
}

int main() {
    int value = 5;

    printf("Before function call: %d\n", value);
    increment(value);
    printf("After function call: %d\n", value);

    return 0;
}

Output:

Before function call: 5
Inside function: 6
After function call: 5
Key Insight: The variable value in main() is never modified because increment() operates on a separate copy.

Why C Uses Pass by Value

Pass by value provides data safety. Since functions cannot accidentally modify the caller’s variables, programs become easier to understand and debug. This behavior is especially useful when working with critical values or constants.

  • Prevents unintended side effects
  • Makes function behavior predictable
  • Easier debugging and testing
Common Mistake: Expecting a function to modify variables in main() when passing them by value. To update the original variable, you must use pointers (covered next).

How Pass by Value Works in Memory

Understanding what happens in memory during a function call helps solidify your understanding. When you call a function with pass by value, the following steps occur:

Step What Happens Memory Location
1. Function Call New stack frame is created for the function Stack grows downward
2. Copy Arguments Values are copied to function parameters New memory in function's stack frame
3. Execute Function Function works with its local copies Only function's stack frame affected
4. Return Stack frame is destroyed, copies are gone Memory is reclaimed

Define a Function That Tries to Modify

void modifyValue(int num) {
    printf("Address of parameter num: %p\n", (void*)&num);
    num = 999;  // Modify the local copy
    printf("Value of num after change: %d\n", num);
}

The function receives a copy of the original value in a new memory location. When the function modifies num, it only changes this local copy. The original variable in the calling function remains completely untouched. This is the fundamental behavior of pass by value in C.

Call the Function and Observe

int main() {
    int original = 42;
    printf("Address of original: %p\n", (void*)&original);
    
    modifyValue(original);  // Pass the value
    
    printf("Value of original after call: %d\n", original);  // Still 42!
    return 0;
}

Output:

Address of original: 0x7ffd5e8a4a5c
Address of parameter num: 0x7ffd5e8a4a3c   // Different address!
Value of num after change: 999
Value of original after call: 42   // Unchanged!
Key Observation: Notice the addresses are different! The parameter num lives at a different memory location than original. They are completely separate variables.

Multiple Parameters

Functions can accept multiple parameters, each passed by value independently. This is useful for operations that require several inputs.

Define Functions with Multiple Parameters:

double calculateArea(double length, double width) {
    return length * width;
}

double calculatePerimeter(double length, double width) {
    return 2 * (length + width);
}

Each parameter is a separate copy - none affect the original variables. When you pass roomLength and roomWidth, the function gets its own copies called length and width. The original variables remain unchanged after the function call.

Use the Functions:

int main() {
    double roomLength = 12.5, roomWidth = 8.3;
    
    double area = calculateArea(roomLength, roomWidth);        // 103.75
    double perimeter = calculatePerimeter(roomLength, roomWidth); // 41.60
    
    printf("Area: %.2f, Perimeter: %.2f\n", area, perimeter);
    return 0;
}

Output:

Room dimensions: 12.5 x 8.3 meters
Area: 103.75 square meters
Perimeter: 41.60 meters

Returning Values: The Pass by Value Solution

If a function cannot modify its parameters (due to pass by value), how do we get results back? The answer is return values. The function computes a result and sends it back to the caller.

Define a Function That Returns a Value:

int addTen(int num) {
    return num + 10;  // Return the result
}

The function computes a new value and returns it - the original parameter is unchanged. The return statement sends the computed result back to the caller. This is the standard way to get results from functions when using pass by value. The caller must capture this returned value to use it.

Capture the Returned Value:

int main() {
    int value = 5;
    value = addTen(value);  // Capture the returned value
    printf("New value: %d\n", value);  // Output: 15
    return 0;
}

Assign the function's return value to a variable to use the result. Here we reassign value to hold the returned result. Without capturing the return value, the computation would be lost. This pattern is very common in C programming.

Best Practice: When a function needs to produce a single result, prefer returning a value rather than modifying a parameter. It makes code clearer and easier to understand.

Pass by Value with Structures

Structures (structs) in C are also passed by value by default. This means the entire structure is copied, which can be inefficient for large structures.

Define a Structure:

struct Point {
    int x;
    int y;
};

Function Receives a COPY of the Structure:

void movePoint(struct Point p, int dx, int dy) {
    p.x += dx;
    p.y += dy;  // Modifies the COPY, not original!
    printf("Inside function: (%d, %d)\n", p.x, p.y);
}

The function receives a complete copy of the structure. All fields (x and y) are copied to a new struct. Any modifications inside the function only affect this copy. The original structure in main() stays unchanged.

Test It:

int main() {
    struct Point origin = {0, 0};
    printf("Before: (%d, %d)\n", origin.x, origin.y);  // (0, 0)
    
    movePoint(origin, 5, 3);
    
    printf("After: (%d, %d)\n", origin.x, origin.y);   // Still (0, 0)!
    return 0;
}
Performance Warning: Large structures passed by value cause significant memory copying. For structures larger than a few bytes, consider passing a pointer instead.

When to Use Pass by Value

Scenario Use Pass by Value? Reason
Simple calculations Yes Safe and simple, return the result
Small data types (int, char, float) Yes Copying is fast and efficient
Read-only operations Yes No need to modify original
Large structures No Copying is expensive, use pointers
Need to modify original No Impossible with pass by value
Arrays N/A Arrays always decay to pointers

Practice Questions: Pass by Value

Test your understanding of how pass by value works in C.

Given:

void change(int x) {
    x = 10;
}

int main() {
    int a = 5;
    change(a);
    printf("%d", a);
}

Task: What will be printed?

Show Solution
// Output: 5
// Explanation: 'a' is passed by value, so only a copy is modified.

Given:

void swap(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
}

int main() {
    int x = 3, y = 7;
    swap(x, y);
    printf("%d %d", x, y);
}

Task: What will be printed and why?

Show Solution
// Output: 3 7
// Explanation: Only copies of x and y are swapped inside the function.
// The original variables remain unchanged.

Given:

struct Point { int x; int y; };

void movePoint(struct Point p, int dx, int dy) {
    p.x += dx;
    p.y += dy;
}

int main() {
    struct Point origin = {0, 0};
    movePoint(origin, 5, 3);
    printf("(%d, %d)", origin.x, origin.y);
}

Task: Predict the output and explain why.

Show Solution
// Output: (0, 0)
// Explanation: Structures are also passed by value. 
// The function modifies a copy, not the original structure.

Given:

void doubleValue(int x) {
    x = x * 2;
}

int main() {
    int num = 5;
    doubleValue(num);
    printf("%d", num);  // Should print 10
}

Task: Fix this function so it correctly doubles the input.

Show Solution
// Solution 1: Return the result
int doubleValue(int x) {
    return x * 2;
}

int main() {
    int num = 5;
    num = doubleValue(num);  // Capture returned value
    printf("%d", num);  // Prints 10
}

// Solution 2: Use pointer (covered in next section)
void doubleValue(int *x) {
    *x = *x * 2;
}
02

Pass by Reference

In C, you can simulate pass by reference using pointers. This allows a function to modify the original variable by passing its address. It's essential for tasks like swapping values or updating data inside a function.

Definition: Pass by reference (simulated using pointers in C) means passing the memory address of a variable instead of its value. Think of it like giving someone your home address instead of a photo of your house - they can now come to your actual house and make changes. The function receives a pointer that "points to" the original variable's location in memory, allowing it to read and modify the original data directly using the dereference operator (*).

Syntax:

return_type function_name(data_type *pointer_name) {
    *pointer_name = new_value;  // modifies the ORIGINAL variable
}

// Calling the function
function_name(&variable);  // passes the ADDRESS of 'variable'

Basic Pointer Parameter Example

#include <stdio.h>

void setZero(int *p) {
    *p = 0;  // Dereference and modify
}

int main() {
    int x = 10;
    printf("Before: %d\n", x);
    setZero(&x);  // Pass address of x
    printf("After: %d\n", x);
    return 0;
}

Output:

Before: 10
After: 0
Key Insight: Using *p lets you change the value at the address passed in, so x is updated in main.

The Classic Swap Problem

One of the most common interview questions is swapping two variables. This demonstrates why pass by reference is essential for certain operations.

Wrong: Pass by Value (Does NOT Work)
void swapWrong(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
    // Only swaps local copies!
}

int main() {
    int x = 5, y = 10;
    swapWrong(x, y);
    printf("x=%d, y=%d\n", x, y);
    // Output: x=5, y=10 (unchanged!)
    return 0;
}
Correct: Pass by Reference (Works!)
void swapCorrect(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
    // Swaps actual values!
}

int main() {
    int x = 5, y = 10;
    swapCorrect(&x, &y);
    printf("x=%d, y=%d\n", x, y);
    // Output: x=10, y=5 (swapped!)
    return 0;
}

Understanding the & and * Operators

Two operators are essential for pass by reference: the address-of operator (&) and the dereference operator (*).

Operator Name Purpose Example
& Address-of Gets the memory address of a variable &x returns address of x
* Dereference Gets/sets the value at an address *ptr accesses value at ptr
* Pointer declaration Declares a pointer variable int *ptr;

Declare Variable and Pointer

int number = 42;
int *ptr = &number;  // ptr stores the address of number

The & operator gets the memory address of number. The pointer ptr stores this address as its value. Now ptr "points to" the memory location where number lives. Both variables are now connected through this address.

Access Values and Addresses

printf("Value of number: %d\n", number);           // 42
printf("Address of number: %p\n", (void*)&number); // 0x7ffd...
printf("Value of ptr (address): %p\n", (void*)ptr);// Same address
printf("Value at ptr (*ptr): %d\n", *ptr);         // 42

The *ptr dereferences the pointer to get the value at that address. Dereferencing means "go to the address stored in the pointer and get the value there". Since ptr points to number, *ptr gives us 42. This is how we access data through pointers.

Modify Through Pointer

*ptr = 100;  // Changes 'number' through the pointer
printf("number = %d\n", number);  // Now 100!

Assigning to *ptr changes the original variable. This works because ptr and number share the same memory. When we write to *ptr, we're writing directly to that memory location. This is the key to modifying variables through pointers.

Multiple Output Parameters

Functions can only return one value. But what if you need to return multiple values? Use pointer parameters as "output parameters" to store multiple results.

Define a Function with Multiple Outputs:

// Returns both quotient and remainder through pointers
void divide(int dividend, int divisor, int *quotient, int *remainder) {
    *quotient = dividend / divisor;
    *remainder = dividend % divisor;
}

Input parameters (dividend, divisor) are passed by value. Output parameters (quotient, remainder) are pointers. The function writes results directly to the memory locations provided. This pattern allows returning multiple values from a single function.

Use the Function:

int main() {
    int a = 17, b = 5;
    int q, r;  // Variables to receive the results
    
    divide(a, b, &q, &r);  // Pass addresses of q and r
    
    printf("%d / %d = %d with remainder %d\n", a, b, q, r);
    // Output: 17 / 5 = 3 with remainder 2
    return 0;
}

The function stores results directly into q and r through their addresses. We pass &q and &r to give the function access to these variables. After the function returns, q contains 3 and r contains 2. This is how C functions return multiple values.

Pattern: Input parameters are passed by value, output parameters are passed by pointer. This is a common C idiom for functions that need to return multiple results.
printf("%d / %d = %d with remainder %d\n", a, b, q, r); return 0; }

Output:

17 / 5 = 3 with remainder 2
Pattern: Input parameters are passed by value, output parameters are passed by pointer. This is a common C idiom for functions that need to return multiple results.

Success/Failure Pattern with Output Parameters

A common C pattern is to return a success/failure status while using an output parameter for the actual result. This allows for proper error handling.

Define a Safe Function with Error Handling:

// Returns true on success, false on error
// Result is stored in *result
bool safeDivide(int a, int b, int *result) {
    if (b == 0) {
        return false;  // Division by zero!
    }
    *result = a / b;
    return true;
}

The function returns a boolean for success/failure status. The actual result is stored through the pointer parameter. This pattern separates error handling from the computed result. Callers can check the return value before using the result.

Use the Function with Error Checking:

int main() {
    int result;
    
    // Successful division
    if (safeDivide(10, 2, &result)) {
        printf("10 / 2 = %d\n", result);  // 10 / 2 = 5
    } else {
        printf("Error: Division by zero!\n");
    }
    
    // Failed division (divide by zero)
    if (safeDivide(10, 0, &result)) {
        printf("10 / 0 = %d\n", result);
    } else {
        printf("Error: Division by zero!\n");  // This prints
    }
    return 0;
}

Output:

10 / 2 = 5
Error: Division by zero!

Modifying Structures via Pointers

When working with structures, passing by pointer is essential for both efficiency (avoiding copies) and the ability to modify the original.

Define a Structure

struct Student {
    char name[50];
    int age;
    float gpa;
};

A structure groups related data together into a single unit. This Student struct holds name, age, and GPA fields. Structures help organize complex data in a meaningful way. They can be passed to functions just like simple variables.

Function to Modify Structure via Pointer

void updateGPA(struct Student *s, float newGPA) {
    s->gpa = newGPA;  // Arrow operator for pointer to struct
}

Use the arrow operator (->) to access members through a pointer. The expression s->gpa is equivalent to (*s).gpa. This modifies the actual structure in the caller's memory. The arrow operator makes pointer-to-struct code much cleaner.

Function to Read Structure (const pointer)

void displayStudent(const struct Student *s) {
    printf("Name: %s, Age: %d, GPA: %.2f\n", s->name, s->age, s->gpa);
}

Use const for read-only access - documents intent and prevents mistakes. The compiler will catch any attempts to modify the structure. This is a best practice for functions that only need to read data. It makes your code safer and more self-documenting.

Using the Functions

int main() {
    struct Student student = {"Alice Johnson", 20, 3.5};
    
    displayStudent(&student);    // Pass address for reading
    updateGPA(&student, 3.8);    // Pass address for modifying
    displayStudent(&student);    // Shows updated GPA
    return 0;
}

Output:

Before update:
Name: Alice Johnson, Age: 20, GPA: 3.50
After update:
Name: Alice Johnson, Age: 20, GPA: 3.80
The Arrow Operator: When you have a pointer to a structure, use ptr->member instead of (*ptr).member. Both are equivalent, but the arrow is cleaner.

NULL Pointer Safety

A major risk with pointer parameters is receiving a NULL pointer. Always validate pointers before dereferencing to prevent crashes.

❌ Unsafe: No NULL Check

void unsafeIncrement(int *ptr) {
    *ptr += 1;  // Crash if ptr is NULL!
}

Dereferencing NULL causes undefined behavior (usually a crash). NULL means the pointer doesn't point to valid memory. Attempting to read or write through NULL is a common bug. Always check for NULL before dereferencing pointers.

✓ Safe: Check for NULL

void safeIncrement(int *ptr) {
    if (ptr != NULL) {
        *ptr += 1;
    }
}

Always validate pointers before using them. This check prevents the crash that would occur with NULL. If the pointer is NULL, the function simply does nothing. This is called defensive programming - handle bad inputs gracefully.

✓ Even Better: Return Status Code

int safeIncrementWithStatus(int *ptr) {
    if (ptr == NULL) {
        return -1;  // Error code
    }
    *ptr += 1;
    return 0;  // Success
}

Returning a status lets the caller know if the operation succeeded. Here, -1 indicates an error and 0 indicates success. The caller can check the return value and handle errors appropriately. This pattern is common in C system programming and libraries.

Usage:

int main() {
    int x = 5;
    
    // Safe usage
    safeIncrement(&x);
    printf("x = %d\n", x);  // x = 6
    
    // This would crash with unsafeIncrement:
    // unsafeIncrement(NULL);
    
    // Safe version handles NULL gracefully
    safeIncrement(NULL);  // Does nothing, no crash
    
    return 0;
}
Critical Rule: ALWAYS check for NULL before dereferencing a pointer. Dereferencing NULL causes a segmentation fault (crash).

Pass by Value vs Pass by Reference Comparison

Aspect Pass by Value Pass by Reference (Pointer)
What is passed? Copy of the value Address (memory location)
Can modify original? No Yes
Memory usage Creates a copy Only pointer size (4-8 bytes)
Syntax func(x) func(&x)
In function param = value *param = value
Safety Very safe Risk of NULL dereference
Use case Small data, read-only Large data, need to modify

Practice Questions: Pass by Reference

Test your understanding of reference passing in C functions.

Given:

#include <stdio.h>

void inc(int *n) {
    *n = *n + 1;
}

int main() {
    int a = 5;
    inc(&a);
    printf("%d\n", a);
    return 0;
}

Task: What will be the output?

Show Solution
// Output: 6
// The function receives the address of 'a' and increments
// the value at that address, modifying the original variable.

Given:

void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main() {
    int x = 5, y = 10;
    swap(&x, &y);
    printf("x = %d, y = %d\n", x, y);
    return 0;
}

Task: Predict the output and explain why this works.

Show Solution
// Output: x = 10, y = 5
// The function swaps values using pointers.
// It accesses and modifies the original variables through their addresses.

Given:

void set(int *p) {
    *p = 5;
}

int main() {
    int a = 10;
    set(NULL);
    printf("%d\n", a);
    return 0;
}

Task: What happens when this code runs? How would you fix it?

Show Solution
// Result: Crash (segmentation fault)
// Dereferencing NULL causes undefined behavior.
// Fix: Add NULL check before dereferencing:
void set(int *p) {
    if (p != NULL) {
        *p = 5;
    }
}

Given:

void safeSet(int *p) {
    if (p != NULL) {
        *p = 10;
    }
}

int main() {
    int a = 5;
    safeSet(&a);
    printf("%d\n", a);
    return 0;
}

Task: What is the output and why is this approach better?

Show Solution
// Output: 10
// This is safer because we check for NULL before dereferencing.
// Always validate pointers to avoid crashes.
03

Passing Arrays

In C, arrays are always passed to functions as pointers to their first element. This means the function can modify the original array, but the array size information is not passed automatically. Understanding this is key to safe and effective array manipulation.

Definition: When you pass an array to a function in C, it decays into a pointer to its first element. This means the function receives a memory address, not a copy of the entire array. Think of it like giving someone the street address of the first house in a neighborhood - they can then visit any house by walking down the street. Because of this decay, the function does NOT know the array's size, so you must pass the size as a separate parameter. Any changes made to array elements inside the function affect the original array.

Syntax:

// Three equivalent ways to declare array parameters:
return_type function_name(data_type arr[], int size);      // Most common
return_type function_name(data_type *arr, int size);       // Pointer notation
return_type function_name(data_type arr[10], int size);    // Size is ignored!

// Calling the function
function_name(array_name, array_size);  // array decays to pointer

Basic Array Passing

When you pass an array to a function, C automatically converts it to a pointer to the first element. This process is called array decay.

#include <stdio.h>

void printArray(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

int main() {
    int data[5] = {10, 20, 30, 40, 50};
    
    printArray(data, 5);  // Pass array and its size
    
    return 0;
}

Output:

10 20 30 40 50
Key Insight: The function receives a pointer, so changes to array elements inside the function affect the original array. Always pass the size separately!

Why Size Must Be Passed Separately

A common beginner question: "Why can't the function figure out the array size?" The answer lies in how C handles arrays.

#include <stdio.h>

void demonstrateSizeProblem(int arr[]) {
    // sizeof(arr) returns pointer size, NOT array size!
    printf("Inside function: sizeof(arr) = %zu bytes\n", sizeof(arr));
    printf("This is the size of a pointer, not the array!\n");
}

int main() {
    int numbers[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    
    printf("In main: sizeof(numbers) = %zu bytes\n", sizeof(numbers));
    printf("Array has %zu elements\n", sizeof(numbers) / sizeof(numbers[0]));
    
    demonstrateSizeProblem(numbers);
    
    return 0;
}

Output (on 64-bit system):

In main: sizeof(numbers) = 40 bytes
Array has 10 elements
Inside function: sizeof(arr) = 8 bytes
This is the size of a pointer, not the array!
Warning: Using sizeof(arr) inside a function gives you the pointer size (typically 4 or 8 bytes), NOT the array size. This is a very common bug!

Modifying Arrays in Functions

Since arrays are passed as pointers, any modifications inside the function affect the original array. This is different from pass by value with simple variables.

Define Functions to Modify Arrays

// Double each element in the array
void doubleElements(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        arr[i] *= 2;  // Modifies original array!
    }
}

// Set all elements to a specific value
void fillArray(int arr[], int size, int value) {
    for (int i = 0; i < size; i++) {
        arr[i] = value;
    }
}

These functions modify the original array directly since arrays are passed as pointers. When you pass an array to a function, C passes a pointer to the first element. Any changes made inside the function affect the original array. This is different from simple variables which are copied.

Create a Helper Function for Printing

void printArray(int arr[], int size) {
    printf("{ ");
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
    printf("}\n");
}

A reusable function to display array contents. This function iterates through the array and prints each element. Notice we must pass the size since arrays decay to pointers. The function cannot determine the array size on its own.

Test the Modifications

int main() {
    int data[5] = {1, 2, 3, 4, 5};
    
    printf("Original: ");
    printArray(data, 5);
    
    doubleElements(data, 5);
    printf("After doubling: ");
    printArray(data, 5);
    
    fillArray(data, 5, 0);
    printf("After fill with 0: ");
    printArray(data, 5);
    
    return 0;
}

Output:

Original: { 1 2 3 4 5 }
After doubling: { 2 4 6 8 10 }
After fill with 0: { 0 0 0 0 0 }

Common Array Operations

Here are some commonly needed array functions that demonstrate proper array passing patterns.

Function 1: Sum All Elements

int sumArray(const int arr[], int size) {
    int sum = 0;
    for (int i = 0; i < size; i++) {
        sum += arr[i];
    }
    return sum;
}

Uses const since we only read the array, not modify it. The const keyword prevents accidental modifications. It also documents the function's intent to other programmers. The compiler will catch any attempts to change the array.

Function 2: Find Maximum Element

int findMax(const int arr[], int size) {
    if (size <= 0) return 0;  // Handle empty array
    
    int max = arr[0];
    for (int i = 1; i < size; i++) {
        if (arr[i] > max) {
            max = arr[i];
        }
    }
    return max;
}

Starts by assuming the first element is the maximum value. Then iterates through the remaining elements one by one. Each element is compared against the current maximum. If a larger value is found, it becomes the new maximum. This approach guarantees finding the true maximum in the array.

Function 3: Find Minimum Element

int findMin(const int arr[], int size) {
    if (size <= 0) return 0;
    
    int min = arr[0];
    for (int i = 1; i < size; i++) {
        if (arr[i] < min) {
            min = arr[i];
        }
    }
    return min;
}

Uses the same algorithm pattern as findMax function. Starts with the first element as the initial minimum. Compares each subsequent element against the current minimum. Updates the minimum when a smaller value is discovered. This mirror approach makes both functions easy to understand together.

Function 4: Calculate Average

double calculateAverage(const int arr[], int size) {
    if (size <= 0) return 0.0;
    return (double)sumArray(arr, size) / size;
}

Demonstrates code reuse by calling the existing sumArray() function. Divides the total sum by the array size to calculate the mean. Returns a double to preserve decimal precision in the result. Shows how functions can be composed to build more complex operations. This modular approach reduces code duplication and improves maintainability.

Using the Functions:

int main() {
    int scores[6] = {85, 92, 78, 95, 88, 76};
    int size = 6;
    
    printf("Sum: %d\n", sumArray(scores, size));       // 514
    printf("Max: %d\n", findMax(scores, size));        // 95
    printf("Min: %d\n", findMin(scores, size));        // 76
    printf("Average: %.2f\n", calculateAverage(scores, size)); // 85.67
    return 0;
}

Output:

Scores: 85 92 78 95 88 76

Sum: 514
Max: 95
Min: 76
Average: 85.67
Best Practice: Use const for array parameters when the function should not modify the array. This prevents accidental modifications and documents intent.

Passing 2D Arrays

Two-dimensional arrays require special syntax. You must specify the column size in the function parameter because the compiler needs it to calculate element positions.

Define Constants for Array Dimensions

#define ROWS 3
#define COLS 4

Preprocessor constants define array dimensions in one place. Changes to ROWS or COLS automatically update all usages. Makes the code self-documenting and easier to understand. Prevents magic numbers scattered throughout the codebase. This is a best practice for any fixed-size array dimensions.

Function with arr[][COLS] Syntax

// For 2D arrays, column size MUST be specified
void print2DArray(int arr[][COLS], int rows) {
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < COLS; j++) {
            printf("%3d ", arr[i][j]);
        }
        printf("\n");
    }
}

The column size must be specified because C stores 2D arrays in row-major order. The compiler needs COLS to calculate memory offsets for element access. Row size can be omitted because only column stride matters for addressing. This is why arr[][COLS] syntax requires the second dimension. The compiler computes element address as: base + (row * COLS + col) * sizeof(int).

Alternative Pointer Syntax

// Alternative: use pointer to array
void print2DArrayAlt(int (*arr)[COLS], int rows) {
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < COLS; j++) {
            printf("%3d ", arr[i][j]);
        }
        printf("\n");
    }
}

The notation int (*arr)[COLS] reads as "pointer to array of COLS integers". Parentheses are crucial because int *arr[COLS] would mean array of pointers. Both syntaxes produce identical machine code when compiled. The pointer notation makes the type relationship more explicit. Choose whichever syntax is clearer for your team and codebase.

Using the Functions

int main() {
    int matrix[ROWS][COLS] = {
        {1, 2, 3, 4},
        {5, 6, 7, 8},
        {9, 10, 11, 12}
    };
    
    print2DArray(matrix, ROWS);
    print2DArrayAlt(matrix, ROWS);  // Same output
    return 0;
}

Output:

Using arr[][COLS] syntax:
  1   2   3   4
  5   6   7   8
  9  10  11  12

Using (*arr)[COLS] syntax:
  1   2   3   4
  5   6   7   8
  9  10  11  12
Syntax Meaning Common Use
int arr[] Pointer to int 1D arrays
int arr[][N] Pointer to array of N ints 2D arrays with fixed columns
int (*arr)[N] Same as above (explicit pointer) 2D arrays, clearer syntax
int **arr Pointer to pointer to int Dynamically allocated 2D arrays

Passing Strings (Character Arrays)

In C, strings are character arrays ending with a null terminator (\0). They follow the same decay rules but have a special advantage: the null terminator marks the end, so you don't always need to pass the size.

Function 1: Print String Info

// String functions don't need size - they look for '\0'
void printString(const char str[]) {
    printf("String: %s\n", str);
    printf("Length: %zu characters\n", strlen(str));
}

The strlen() function from string.h returns the string length. It counts characters by scanning until it finds the null terminator. The null character itself is not included in the returned count. This is why strings don't need explicit size parameters passed. Always ensure strings are properly null-terminated before using strlen().

Function 2: Count Vowels

int countVowels(const char str[]) {
    int count = 0;
    for (int i = 0; str[i] != '\0'; i++) {
        char c = str[i];
        if (c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u' ||
            c == 'A' || c == 'E' || c == 'I' || c == 'O' || c == 'U') {
            count++;
        }
    }
    return count;
}

The loop continues as long as the current character is not null. The condition str[i] != '\0' automatically stops at string end. No size parameter is needed because strings are self-delimiting. This is a key advantage of null-terminated strings in C. Always use this pattern when iterating through strings character by character.

Function 3: Convert to Uppercase (Modifies Original)

void toUpperCase(char str[]) {  // No const - we modify it!
    for (int i = 0; str[i] != '\0'; i++) {
        if (str[i] >= 'a' && str[i] <= 'z') {
            str[i] = str[i] - 32;  // ASCII conversion
        }
    }
}

This function modifies the original string in place. Without const, the function is allowed to change array contents. The absence of const signals to callers that data will be modified. This is an important documentation practice in C programming. Always omit const when your function intentionally modifies parameters.

Using the Functions:

int main() {
    char message[] = "Hello, World!";
    
    printString(message);       // String: Hello, World!, Length: 13
    printf("Vowels: %d\n", countVowels(message));  // 3
    
    toUpperCase(message);
    printf("After uppercase: %s\n", message);  // HELLO, WORLD!
    return 0;
}

Output:

String: Hello, World!
Length: 13 characters
Vowels: 3

After uppercase: HELLO, WORLD!

Array Parameter Best Practices

Do This
  • Always pass array size as a parameter
  • Use const for read-only arrays
  • Validate size before accessing elements
  • Document expected array format
  • Use meaningful parameter names
Don't Do This
  • Use sizeof(arr) inside functions
  • Assume a specific array size
  • Access elements without bounds checking
  • Modify arrays without documenting it
  • Forget the null terminator for strings

Practice Questions: Passing Arrays

Test your understanding of array passing in C functions.

Given:

#include <stdio.h>

void zero(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        arr[i] = 0;
    }
}

int main() {
    int b[3] = {1, 2, 3};
    zero(b, 3);
    printf("b = {%d, %d, %d}\n", b[0], b[1], b[2]);
    return 0;
}

Task: Predict the output.

Show Solution
// Output: b = {0, 0, 0}
// The function modifies the original array elements
// because arrays are passed by reference (as pointers).

Given:

int numbers[] = {10, 20, 30, 40, 50};

Task: Write a function that takes an array and its size, returns the sum of all elements.

Show Solution
int sumArray(int arr[], int size) {
    int sum = 0;
    for (int i = 0; i < size; i++) {
        sum += arr[i];
    }
    return sum;
}

int main() {
    int numbers[] = {10, 20, 30, 40, 50};
    int result = sumArray(numbers, 5);
    printf("Sum: %d\n", result);  // Output: Sum: 150
    return 0;
}

Given:

void printSize(int arr[]) {
    printf("Size: %lu\n", sizeof(arr));
}

int main() {
    int b[5] = {1, 2, 3, 4, 5};
    printf("In main: %lu\n", sizeof(b));
    printSize(b);
    return 0;
}

Task: Explain why the sizes are different.

Show Solution
// Output on 64-bit system:
// In main: 20      (5 ints × 4 bytes = 20)
// Size: 8          (size of pointer, not array!)

// Explanation: When an array is passed to a function,
// it "decays" into a pointer. sizeof(arr) inside the
// function gives the pointer size, not the array size.
// This is why you must always pass the size separately!

Given:

void foo(int arr[], int n) { /* ... */ }
void bar(int *arr, int n) { /* ... */ }

Task: Are these declarations equivalent? Explain.

Show Solution
// Yes, they are EQUIVALENT!
// Both receive a pointer to the first element.
// int arr[] and int *arr are identical in function parameters.
// The [] syntax is just "syntactic sugar" - it makes the 
// intent clearer that we expect an array, but the 
// compiler treats them the same way.
04

Parameter Modifiers

C allows you to use modifiers like const and restrict with function parameters. These modifiers help you write safer and more efficient code by controlling how parameters are used inside functions.

Definition: Parameter modifiers are keywords that add extra rules about how a parameter can be used. const creates a "read-only" promise - the function cannot modify the data (like a "Do Not Edit" stamp on a document). restrict (added in C99) is a performance hint telling the compiler that this pointer is the only way to access that memory region, allowing better optimization. volatile tells the compiler the value might change unexpectedly (used with hardware registers). These modifiers make code safer and can improve performance.

Syntax:

// const - prevents modification (read-only)
void print_data(const int *ptr);        // cannot modify *ptr
void print_array(const int arr[], int n); // cannot modify arr elements

// restrict - optimization hint (C99)
void copy(int *restrict dest, const int *restrict src, int n);

// Combining modifiers
void process(const int *restrict data, int size);

The const Keyword in Detail

The const keyword is one of the most important tools for writing safe C code. It tells the compiler (and other programmers) that a value should not be modified.

Read-Only Function (with const):

// Function promises NOT to modify the array
void printArray(const int arr[], int size) {
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
    
    // This would cause a compiler error:
    // arr[0] = 999;  // Error: assignment of read-only location
}

The const keyword tells the compiler this data should not change. Any attempt to modify through this pointer causes a compile error. This catches accidental modification bugs at compile time, not runtime. The const promise also helps other programmers understand your intent. It's a form of documentation that the compiler actually enforces.

Modifying Function (without const):

// Function WILL modify the array (no const)
void doubleArray(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        arr[i] *= 2;  // This is allowed
    }
}

Without the const qualifier, the function can freely modify elements. This signals to callers that their data may be changed. The absence of const is intentional and meaningful documentation. Use this when your function's purpose includes modifying the array. Readers can immediately tell this function has side effects on the data.

Using Both Functions:

int main() {
    int numbers[] = {1, 2, 3, 4, 5};
    int size = 5;
    
    printf("Original: ");
    printArray(numbers, size);
    
    doubleArray(numbers, size);
    
    printf("Doubled: ");
    printArray(numbers, size);
    return 0;
}

Output:

Original: 1 2 3 4 5
Doubled: 2 4 6 8 10
Best Practice: Always use const for parameters that should not be modified. It documents intent, catches bugs at compile time, and enables certain optimizations.

Understanding const with Pointers

With pointers, const can apply to the data being pointed to, the pointer itself, or both. The placement of const determines what is constant.

Declaration Pointer Changeable? Data Changeable? Description
int *p Yes Yes Normal pointer
const int *p Yes No Pointer to constant data
int *const p No Yes Constant pointer to data
const int *const p No No Constant pointer to constant data
#include <stdio.h>

int main() {
    int x = 10, y = 20;
    
    // 1. Pointer to const: can't modify data through pointer
    const int *ptr1 = &x;
    // *ptr1 = 100;  // Error: assignment of read-only location
    ptr1 = &y;       // OK: can change where it points
    
    // 2. Const pointer: can't change where it points
    int *const ptr2 = &x;
    *ptr2 = 100;     // OK: can modify data
    // ptr2 = &y;    // Error: assignment of read-only variable
    
    // 3. Const pointer to const: can't do either
    const int *const ptr3 = &x;
    // *ptr3 = 100;  // Error: assignment of read-only location
    // ptr3 = &y;    // Error: assignment of read-only variable
    
    printf("x = %d\n", x);
    return 0;
}

Output:

x = 100
Memory Trick: Read declarations from right to left. const int *p = "p is a pointer to an int that is const" (can't modify data). int *const p = "p is a const pointer to an int" (can't change pointer).

The restrict Keyword (C99)

The restrict keyword is a performance optimization hint for the compiler. It promises that the pointer is the only way to access that memory, allowing the compiler to generate more efficient code.

Without restrict (conservative compilation):

// Compiler must assume src and dest might overlap
void copyArraySlow(int *dest, const int *src, int n) {
    for (int i = 0; i < n; i++) {
        dest[i] = src[i];
    }
}

Without restrict, the compiler must assume pointers might overlap. This forces conservative code generation with extra memory loads. The compiler can't cache values because another pointer might change them. Loop optimizations like vectorization may be disabled for safety. This is correct but potentially slower than optimized code.

With restrict (optimized compilation):

// Compiler knows they don't overlap, can optimize
void copyArrayFast(int *restrict dest, const int *restrict src, int n) {
    for (int i = 0; i < n; i++) {
        dest[i] = src[i];
    }
}

The restrict keyword promises these pointers don't alias each other. The compiler can now assume no overlap between src and dest. This enables aggressive optimizations like SIMD vectorization. Loop iterations can be processed in parallel for better performance. Only use restrict when you can guarantee the pointers don't overlap.

Usage Example:

int main() {
    int source[] = {1, 2, 3, 4, 5};
    int destination[5];
    
    copyArrayFast(destination, source, 5);
    
    // Prints: Copied: 1 2 3 4 5
    return 0;
}
Warning: If you use restrict and the pointers actually DO overlap (aliasing), you get undefined behavior. Only use it when you're certain!

Real-World Examples of Parameter Modifiers

The C standard library uses these modifiers extensively. Understanding them helps you read and use library functions correctly.

// From string.h - notice the modifier patterns

// strlen: only reads the string, uses const
size_t strlen(const char *s);

// strcpy: dest is modified, src is read-only, both use restrict
char *strcpy(char *restrict dest, const char *restrict src);

// memcpy: same pattern as strcpy
void *memcpy(void *restrict dest, const void *restrict src, size_t n);

// strcmp: compares two strings, neither modified
int strcmp(const char *s1, const char *s2);

// qsort: compare function uses const void pointers
void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));
Pattern Recognition: In standard library functions, const parameters are inputs (source), and non-const parameters are outputs (destination). restrict indicates the pointers must not overlap.

Combining Multiple Modifiers

For maximum safety and performance, you can combine modifiers. This is common in high-performance code.

Example 1: Processing Arrays with restrict and const

// Read-only input, modifiable output, no overlap guaranteed
void processData(int *restrict output, 
                 const int *restrict input, 
                 int size, 
                 int multiplier) {
    for (int i = 0; i < size; i++) {
        output[i] = input[i] * multiplier;
    }
}

The const modifier ensures the input array remains unchanged. The restrict modifier promises no overlap between input and output. Together, they provide both safety guarantees and optimization hints. This pattern is common in high-performance data processing code. Standard library functions like memcpy use this exact combination.

Example 2: Maximum Protection with const pointer to const data

// Neither the data nor the pointer can be modified
void analyzeData(const int *const data, int size) {
    int sum = 0;
    for (int i = 0; i < size; i++) {
        sum += data[i];
    }
    printf("Sum: %d, Average: %.2f\n", sum, (double)sum / size);
}

The first const (const int*) prevents modifying the pointed-to data. The second const (*const) prevents changing where the pointer points. This provides maximum protection for read-only analysis functions. Neither the array elements nor the pointer itself can be changed. Use this pattern when the function should be purely observational.

Using the Functions:

int main() {
    int source[] = {1, 2, 3, 4, 5};
    int result[5];
    
    processData(result, source, 5, 3);  // Multiply each by 3
    analyzeData(result, 5);             // Analyze the result
    return 0;
}
// Output: Sum: 45, Average: 9.00

Output:

Result: 3 6 9 12 15
Sum: 45, Average: 9.00

When to Use Each Modifier

Modifier Use When Example Use Case
const Parameter should not be modified Print functions, search functions
restrict Pointers definitely don't overlap Copy functions, transformation functions
const + restrict Read-only input with no aliasing Source parameter in copy operations
volatile Value may change unexpectedly Hardware registers, signal handlers
Best Practices Summary:
  • Use const for all read-only parameters (arrays, strings, structs)
  • Use restrict in performance-critical code when pointers don't overlap
  • Document your assumptions when using restrict
  • Look at standard library function signatures as examples

Practice Questions: Parameter Modifiers

Test your understanding of parameter modifiers in C functions.

Given:

void foo(const int x) {
    x = 10;  // What happens here?
}

Task: What error occurs and why?

Show Solution
// Compiler error: assignment of read-only variable 'x'
// The const keyword prevents modification at compile time.
// This provides safety by catching mistakes early.

Given:

void copy(int * restrict dest, const int * restrict src, int n) {
    for (int i = 0; i < n; i++) {
        dest[i] = src[i];
    }
}

Task: Explain the purpose of restrict here.

Show Solution
// restrict tells the compiler that dest and src point to 
// non-overlapping memory regions. This allows the compiler 
// to perform aggressive optimizations (like vectorization).
// Without restrict, the compiler must assume dest and src 
// might overlap, preventing many optimizations.

Given:

void analyze(const int * const p) {
    // What can you do with p?
}

Task: Explain what each const means.

Show Solution
// const int * const p means:
// 1. First const: Cannot modify the VALUE pointed to (*p = 5 is error)
// 2. Second const: Cannot modify the POINTER itself (p = &other is error)
// You can only READ through the pointer, and cannot reassign it.

Given:

void printString(const char *str) {
    printf("%s\n", str);
}

Task: Why is const important here?

Show Solution
// Benefits of using const:
// 1. Documents intent: The function won't modify the string
// 2. Safety: Compiler catches accidental modifications
// 3. Flexibility: Can accept both char[] and string literals
// 4. Optimization: Compiler can make better optimizations
05

Advanced Parameter Patterns

Beyond the basics, C offers powerful patterns for working with function parameters. These advanced techniques are essential for writing professional-grade code that is efficient, maintainable, and handles complex data structures.

Definition: Advanced parameter patterns are techniques for handling complex function parameters including callback functions (function pointers), variable-length argument lists (variadic functions), and opaque pointers for data hiding. Think of these as the "power tools" of C programming - they require more skill to use but enable you to build sophisticated software like operating systems, libraries, and high-performance applications.

5.1 Function Pointers as Parameters

A function pointer holds the address of a function. When passed as a parameter, it allows one function to call another function that's chosen at runtime. This is the foundation of callback mechanisms in C.

Define the Function Pointer Type

// Syntax: return_type (*pointer_name)(parameter_types)
// Function pointer type for functions taking int and returning int
typedef int (*MathOperation)(int, int);

The typedef creates an alias called MathOperation for function pointers. Any function matching this signature can be stored in this type. The signature specifies two int parameters and an int return value. This makes function pointer declarations much more readable. Without typedef, the syntax for function pointers is quite complex.

Create Functions That Match the Signature

int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }
int divide(int a, int b) { return b != 0 ? a / b : 0; }

All four functions have identical signatures matching MathOperation. They each accept two int parameters named a and b. Each returns an int result from their respective operation. This uniformity allows them to be used interchangeably as callbacks. The compiler ensures type safety when assigning to MathOperation variables.

Create a Function That Accepts Function Pointer

int calculate(int x, int y, MathOperation op) {
    return op(x, y);  // Call the function through the pointer
}

The calculate function accepts a function pointer as its third parameter. It doesn't know which specific operation will be performed at compile time. The actual function is determined at runtime by the caller's choice. Using op(x, y) invokes whatever function was passed in. This is the strategy pattern in C - choosing algorithms at runtime.

Use the Function Pointer

int main() {
    printf("10 + 5 = %d\n", calculate(10, 5, add));        // 15
    printf("10 - 5 = %d\n", calculate(10, 5, subtract));   // 5
    printf("10 * 5 = %d\n", calculate(10, 5, multiply));   // 50
    printf("10 / 5 = %d\n", calculate(10, 5, divide));     // 2
    return 0;
}

Each call to calculate() passes a different math function. The same calculate() interface handles addition, subtraction, and more. This demonstrates runtime polymorphism in C using function pointers. Adding new operations requires only defining new matching functions. The core calculate() logic never needs to change.

Why Use Function Pointers?
  • Callbacks: Pass a function to be called when an event occurs
  • Strategy Pattern: Choose different algorithms at runtime
  • Generic Algorithms: Write functions that work with any comparison logic
  • Event Handlers: Register functions to respond to user input

Real-World Example: Custom Sorting with qsort()

The standard library's qsort() function uses a function pointer to compare elements, making it work with any data type:

Include Required Headers

#include <stdio.h>
#include <stdlib.h>  // For qsort()
#include <string.h>  // For strcmp()

The stdlib.h header provides the qsort() generic sorting function. The string.h header provides strcmp() for comparing strings. These are standard C library headers available on all platforms. Including them gives access to many useful utility functions. Always include the correct headers to avoid implicit declarations.

Write Comparison Functions

// Comparison function for integers (ascending)
int compareInts(const void *a, const void *b) {
    return (*(int*)a - *(int*)b);
}

// Comparison function for integers (descending)
int compareIntsDesc(const void *a, const void *b) {
    return (*(int*)b - *(int*)a);
}

// Comparison function for strings
int compareStrings(const void *a, const void *b) {
    return strcmp(*(const char**)a, *(const char**)b);
}

Comparison functions receive const void* because qsort works with any type. You must cast these generic pointers to your actual data type. Return a negative value if the first argument is less than the second. Return a positive value if the first argument is greater than the second. Return zero if both arguments are equal in sort order.

Sort Integer Array

int numbers[] = {64, 34, 25, 12, 22, 11, 90};
int n = sizeof(numbers) / sizeof(numbers[0]);

qsort(numbers, n, sizeof(int), compareInts);

printf("Sorted ascending: ");
for (int i = 0; i < n; i++) {
    printf("%d ", numbers[i]);
}
printf("\n");  // Output: 11 12 22 25 34 64 90

The qsort function requires four arguments to sort any array. First: pointer to the array base address. Second: number of elements in the array. Third: size of each element in bytes using sizeof. Fourth: pointer to a comparison function for ordering.

Sort String Array

const char *names[] = {"Charlie", "Alice", "Bob", "Diana"};
int nameCount = sizeof(names) / sizeof(names[0]);

qsort(names, nameCount, sizeof(char*), compareStrings);

printf("Sorted names: ");
for (int i = 0; i < nameCount; i++) {
    printf("%s ", names[i]);
}
printf("\n");  // Output: Alice Bob Charlie Diana

For an array of strings, each element is a char* pointer. The element size is sizeof(char*), not the string length. We're sorting pointers to strings, not the strings themselves. The comparison function receives pointers to char* (i.e., char**). This double indirection requires casting to (const char**) first.

5.2 Variadic Functions (Variable Arguments)

Variadic functions can accept a variable number of arguments. You've already used one - printf()! The <stdarg.h> header provides macros to work with variable arguments.

Include Headers and Declare Function

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

// Variadic function to sum any number of integers
int sum(int count, ...) {

The three dots (...) in the parameter list indicate variable arguments. At least one fixed parameter must come before the ellipsis. The count parameter tells the function how many extra arguments follow. This is necessary because C has no built-in way to count varargs. The function signature clearly shows it accepts a flexible number of values.

Initialize and Process Arguments

    va_list args;           // Declare variable argument list
    va_start(args, count);  // Initialize with last fixed parameter
    
    int total = 0;
    for (int i = 0; i < count; i++) {
        total += va_arg(args, int);  // Get next argument as int
    }
    
    va_end(args);           // Clean up
    return total;
}

The va_start macro initializes the va_list for argument retrieval. Pass the last fixed parameter name so it knows where varargs begin. The va_arg macro retrieves the next argument with the specified type. Always call va_end when finished to clean up internal state. These macros handle the low-level stack manipulation for you.

Another Example - Finding Maximum

int max(int count, ...) {
    va_list args;
    va_start(args, count);
    
    int maximum = va_arg(args, int);  // First value is initial max
    for (int i = 1; i < count; i++) {
        int value = va_arg(args, int);
        if (value > maximum) {
            maximum = value;
        }
    }
    
    va_end(args);
    return maximum;
}

This function demonstrates finding the maximum among variable arguments. The first value retrieved becomes the initial maximum for comparison. Each subsequent value is compared against the current maximum. If a larger value is found, it replaces the current maximum. The loop uses count to know exactly how many arguments to process.

Using Variadic Functions

int main() {
    printf("Sum of 1,2,3 = %d\n", sum(3, 1, 2, 3));           // 6
    printf("Sum of 1,2,3,4,5 = %d\n", sum(5, 1, 2, 3, 4, 5)); // 15
    printf("Max of 5,2,8,1 = %d\n", max(4, 5, 2, 8, 1));      // 8
    return 0;
}

The first argument is always the count of values that follow. Callers must correctly specify how many arguments they're passing. sum(3, 1, 2, 3) means "sum the next 3 values: 1, 2, and 3". Getting the count wrong leads to undefined behavior. This pattern is common but requires careful attention from callers.

Macro Purpose Usage
va_list Type for variable argument list va_list args;
va_start() Initialize the list va_start(args, lastFixed);
va_arg() Get next argument of specified type int val = va_arg(args, int);
va_end() Clean up the list va_end(args);
va_copy() Copy the list (C99+) va_copy(dest, src);

Building a Custom Printf

Function Declaration and Setup

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

// Simplified printf supporting %d, %s, %c
void myPrintf(const char *format, ...) {
    va_list args;
    va_start(args, format);

The format string is the last fixed parameter before the varargs. We pass "format" to va_start because arguments follow after it. The format string will be parsed to find format specifiers. Each specifier indicates the type of the next argument to retrieve. This mimics how the standard printf() function works internally.

Process Format String Character by Character

    while (*format != '\0') {
        if (*format == '%') {
            format++;  // Move past '%'
            switch (*format) {
                case 'd':
                    printf("%d", va_arg(args, int));
                    break;
                case 's':
                    printf("%s", va_arg(args, char*));
                    break;
                case 'c':
                    printf("%c", va_arg(args, int));  // char promoted to int
                    break;
                case '%':
                    putchar('%');
                    break;
            }
        } else {
            putchar(*format);
        }
        format++;
    }
    
    va_end(args);
}

When the % character is found, the next character is the format specifier. Each specifier (d, s, c) tells us what type to retrieve with va_arg. %d means retrieve an int, %s means retrieve a char*, and so on. The switch statement handles each supported format specifier. The %% sequence outputs a literal percent sign without consuming an argument.

Using the Custom Printf

int main() {
    myPrintf("Hello, %s! You are %d years old.\n", "Alice", 25);
    myPrintf("Grade: %c, Score: %d%%\n", 'A', 95);
    return 0;
}

Our myPrintf function works identically to standard printf for supported formats. The format string contains both literal text and format specifiers. Arguments after the format string match the specifiers in order. This demonstrates how variadic functions enable flexible APIs. The real printf supports many more format options and modifiers.

5.3 Opaque Pointers for Data Hiding

Opaque pointers hide implementation details from users of your API. The header file declares a pointer to an incomplete type, and only the implementation file knows the actual structure definition.

stack.h (Public Interface)
// Only declare the type, don't define it
typedef struct Stack Stack;

// Public API - users only work with Stack*
Stack* stack_create(int capacity);
void stack_destroy(Stack *s);
void stack_push(Stack *s, int value);
int stack_pop(Stack *s);
int stack_peek(const Stack *s);
int stack_isEmpty(const Stack *s);
int stack_size(const Stack *s);
stack.c (Private Implementation)
#include "stack.h"
#include <stdlib.h>

// Private definition - only known here
struct Stack {
    int *data;
    int top;
    int capacity;
};

Stack* stack_create(int capacity) {
    Stack *s = malloc(sizeof(Stack));
    s->data = malloc(capacity * sizeof(int));
    s->top = -1;
    s->capacity = capacity;
    return s;
}

void stack_push(Stack *s, int value) {
    if (s->top < s->capacity - 1) {
        s->data[++s->top] = value;
    }
}

int stack_pop(Stack *s) {
    if (s->top >= 0) {
        return s->data[s->top--];
    }
    return -1;  // Error value
}
Benefits of Opaque Pointers:
  • Encapsulation: Users can't access internal data directly
  • Binary Compatibility: Change struct without recompiling users
  • Reduced Dependencies: Users don't need internal headers
  • Easier Testing: Mock implementations are simpler

5.4 Compound Literals as Parameters (C99+)

Compound literals create unnamed objects that can be passed directly to functions without declaring separate variables first.

Define Structure and Functions

#include <stdio.h>

struct Point { int x; int y; };

void printPoint(struct Point p) {
    printf("Point(%d, %d)\n", p.x, p.y);
}

void printArray(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

These are ordinary functions that accept struct or array parameters. printPoint takes a Point struct by value, copying the entire structure. printArray takes an array pointer and size for iteration. Both functions will be used to demonstrate compound literals. The function definitions remain unchanged when using compound literals.

Traditional vs Compound Literal

// Traditional way - requires separate variable
struct Point p1 = {10, 20};
printPoint(p1);

// Using compound literal - no variable needed!
printPoint((struct Point){30, 40});

Compound literals let you create and pass a struct in one expression. The syntax (struct Point){30, 40} creates an unnamed temporary Point. This object exists for the duration of the expression or block. No separate variable declaration is needed for single-use values. This makes function calls more concise and reduces code clutter.

Passing Arrays and Getting Pointers

// Pass array directly without declaring a variable
printArray((int[]){1, 2, 3, 4, 5}, 5);

// Pointer to compound literal (valid in same block)
struct Point *ptr = &(struct Point){50, 60};
printf("Via pointer: Point(%d, %d)\n", ptr->x, ptr->y);

You can take the address of a compound literal with the & operator. The resulting pointer is valid within the current block scope. Once the block ends, the compound literal ceases to exist. Using the pointer after the block causes undefined behavior. Compound literals for arrays work the same way with implicit size.

5.5 Designated Initializers in Parameters (C99+)

Combined with compound literals, designated initializers allow you to specify which struct members to initialize, leaving others as zero.

Define a Configuration Struct

#include <stdio.h>

struct Config {
    int width;
    int height;
    int depth;
    int color;
    int alpha;
};

This struct has five fields representing configuration options. Some fields like depth, color, and alpha may have sensible defaults. Designated initializers let you set only the fields you care about. Unspecified fields are automatically initialized to zero. This pattern is excellent for configuration with many optional settings.

Create a Function That Accepts Config

void configure(struct Config cfg) {
    printf("Config: %dx%dx%d, color=%d, alpha=%d\n",
           cfg.width, cfg.height, cfg.depth, cfg.color, cfg.alpha);
}

The function receives the entire struct by value on the stack. It can access all fields regardless of how they were initialized. Fields set by the caller contain their specified values. Fields not specified by the caller contain zero by default. This separation allows flexible, readable function calls.

Use Designated Initializers

// Only specify the values you care about
configure((struct Config){.width = 800, .height = 600});
// Output: Config: 800x600x0, color=0, alpha=0

// More explicit configuration
configure((struct Config){
    .width = 1920,
    .height = 1080,
    .color = 0xFF0000,
    .alpha = 255
});

Use .fieldname = value to initialize specific fields. Unspecified fields are automatically set to zero.

return 0; }
Pro Tip: Designated initializers make code self-documenting. Instead of guessing what {800, 600, 32, 0xFF, 255} means, {.width=800, .height=600} is crystal clear!

5.6 Simulating Default Parameters in C

Unlike C++ or Python, C does not support default parameter values natively. However, there are several patterns to simulate this behavior, making your APIs more flexible and user-friendly.

Why Default Parameters Matter: Default parameters allow functions to be called with fewer arguments, using predefined values for omitted ones. This reduces code duplication and makes APIs easier to use. Since C lacks this feature, we use workarounds like wrapper functions, macros, or struct-based configurations.

Method 1: Wrapper Functions

Create multiple functions with different parameter counts, where simpler versions call the full version with default values.

Define the Full Function

// Full function with all parameters
void drawRectangle(int x, int y, int width, int height, int color, int filled) {
    printf("Rectangle at (%d,%d), size %dx%d, color=%d, filled=%d\n",
           x, y, width, height, color, filled);
}

This is the complete function with all parameters explicitly specified. Every aspect of the rectangle is configurable by the caller. This function will be called by the wrapper functions. It serves as the single source of truth for the implementation. All variants ultimately delegate to this full version.

Create Wrapper Functions with Defaults

// Wrapper with default color (black) and filled (false)
void drawRectangleSimple(int x, int y, int width, int height) {
    drawRectangle(x, y, width, height, 0x000000, 0);
}

// Wrapper with default filled (false)
void drawRectangleColored(int x, int y, int width, int height, int color) {
    drawRectangle(x, y, width, height, color, 0);
}

Each wrapper provides a simplified interface for common use cases. Default values are hardcoded in the wrapper function calls. Callers choose the wrapper that matches their needs. This pattern is used extensively in the C standard library. For example, malloc() is simpler than the full calloc() interface.

Using the Functions

int main() {
    // Full control - specify everything
    drawRectangle(0, 0, 100, 50, 0xFF0000, 1);
    
    // Simple version - uses default color and filled
    drawRectangleSimple(10, 10, 80, 40);
    
    // Colored version - uses default filled
    drawRectangleColored(20, 20, 60, 30, 0x00FF00);
    
    return 0;
}

Method 2: Macro-Based Defaults

Use preprocessor macros to provide default values when arguments are omitted. This approach uses variadic macros and token counting.

Define the Core Function

// The actual implementation
void greet_impl(const char *name, const char *greeting, int times) {
    for (int i = 0; i < times; i++) {
        printf("%s, %s!\n", greeting, name);
    }
}

The implementation function has a suffix like _impl to distinguish it. All parameters are required for this underlying function. The macro will provide defaults and call this function. This separation keeps the logic clean and testable. Users interact with the macro, not this function directly.

Create Macros for Default Arguments

// Macro overloading based on argument count
#define greet1(name)                 greet_impl(name, "Hello", 1)
#define greet2(name, greeting)       greet_impl(name, greeting, 1)
#define greet3(name, greeting, times) greet_impl(name, greeting, times)

// Argument counting macro
#define GET_MACRO(_1, _2, _3, NAME, ...) NAME
#define greet(...) GET_MACRO(__VA_ARGS__, greet3, greet2, greet1)(__VA_ARGS__)

Multiple macros handle different numbers of arguments. GET_MACRO selects the right version based on argument count. The final greet() macro dispatches to the correct version. This technique is powerful but can be complex to debug. Compiler errors may be less clear when macros are involved.

Using the Macro

int main() {
    greet("World");                    // Hello, World! (1 time)
    greet("Alice", "Good morning");    // Good morning, Alice! (1 time)
    greet("Bob", "Hi", 3);             // Hi, Bob! (3 times)
    
    return 0;
}

Method 3: Struct with Defaults (Recommended)

Use a configuration struct with a default initializer. This is the cleanest and most maintainable approach for complex functions.

Define the Options Struct

// Options struct with all configurable parameters
typedef struct {
    int width;
    int height;
    int color;
    int borderWidth;
    int filled;
    float opacity;
} WindowOptions;

Group all optional parameters into a single struct. Each field represents one configurable aspect. This scales well when you have many optional parameters. Adding new options doesn't change existing call sites. The struct serves as self-documenting configuration.

Create a Default Options Function or Macro

// Function that returns default options
WindowOptions defaultWindowOptions(void) {
    return (WindowOptions){
        .width = 800,
        .height = 600,
        .color = 0xFFFFFF,
        .borderWidth = 1,
        .filled = 1,
        .opacity = 1.0f
    };
}

// Or use a macro for compile-time initialization
#define DEFAULT_WINDOW_OPTIONS (WindowOptions){ \
    .width = 800, .height = 600, \
    .color = 0xFFFFFF, .borderWidth = 1, \
    .filled = 1, .opacity = 1.0f \
}

The defaults function returns a fully initialized struct. Callers can modify only the fields they care about. The macro version works at compile time for static initialization. Both approaches ensure all fields have sensible values. This pattern is common in graphics and system libraries.

The Function That Uses Options

void createWindow(const char *title, WindowOptions opts) {
    printf("Creating window '%s'\n", title);
    printf("  Size: %dx%d\n", opts.width, opts.height);
    printf("  Color: 0x%06X, Border: %d\n", opts.color, opts.borderWidth);
    printf("  Filled: %s, Opacity: %.1f\n", 
           opts.filled ? "yes" : "no", opts.opacity);
}

The function accepts the title and an options struct. It doesn't need to know which fields were customized. All fields are guaranteed to have valid values. This makes the function implementation straightforward. The options struct can be reused across multiple calls.

Using with Custom Values

int main() {
    // Use all defaults
    createWindow("Default Window", defaultWindowOptions());
    
    // Customize some options
    WindowOptions opts = defaultWindowOptions();
    opts.width = 1920;
    opts.height = 1080;
    opts.opacity = 0.9f;
    createWindow("Custom Window", opts);
    
    // One-liner with designated initializers (C99+)
    createWindow("Quick Window", (WindowOptions){
        .width = 640, .height = 480,
        .color = 0x000000, .borderWidth = 2,
        .filled = 0, .opacity = 0.8f
    });
    
    return 0;
}

Method 4: Sentinel Values

Use special values (like -1 or NULL) to indicate "use default". The function checks for these sentinel values and substitutes defaults.

Function with Sentinel Detection

void printMessage(const char *msg, int times, const char *prefix) {
    // Use defaults for sentinel values
    if (times <= 0) times = 1;              // Default: 1 time
    if (prefix == NULL) prefix = "INFO";    // Default: "INFO"
    
    for (int i = 0; i < times; i++) {
        printf("[%s] %s\n", prefix, msg);
    }
}

The function checks each parameter for sentinel values. Negative or zero times triggers the default of 1. NULL prefix triggers the default "INFO" string. This approach works well for simple functions. Be careful that sentinel values don't conflict with valid inputs.

Using Sentinel Values

int main() {
    printMessage("Hello", 3, "DEBUG");      // [DEBUG] Hello (3 times)
    printMessage("Warning!", 0, "WARN");    // [WARN] Warning! (1 time, default)
    printMessage("Test", 2, NULL);          // [INFO] Test (2 times, default prefix)
    printMessage("Default", 0, NULL);       // [INFO] Default (all defaults)
    
    return 0;
}
Method Pros Cons Best For
Wrapper Functions Simple, type-safe, clear intent Many function names, code duplication 2-3 common parameter combinations
Macro Overloading Single name, flexible Complex, hard to debug, cryptic errors Library APIs, advanced users
Struct with Defaults Scalable, self-documenting, maintainable More verbose for simple cases Many optional parameters
Sentinel Values Simple, backward compatible Sentinel may conflict with valid values Legacy code, simple functions
Recommendation: For new code with multiple optional parameters, use the struct with defaults approach. It's the most maintainable, scales well, and is self-documenting. Many modern C libraries (like SDL, GLFW) use this pattern extensively.

Practice Questions: Advanced Patterns

Test your understanding of advanced parameter patterns.

Given:

int square(int x) { return x * x; }
int cube(int x) { return x * x * x; }

int apply(int (*func)(int), int value) {
    return func(value);
}

int main() {
    printf("%d\n", apply(square, 5));
    printf("%d\n", apply(cube, 3));
}

Task: Predict the output.

Show Solution
// Output:
// 25   (square(5) = 5 * 5 = 25)
// 27   (cube(3) = 3 * 3 * 3 = 27)

Given:

double average(int count, ...) {
    // Your code here
}

Task: Complete this function to return the average of all arguments.

Show Solution
double average(int count, ...) {
    va_list args;
    va_start(args, count);
    
    double sum = 0;
    for (int i = 0; i < count; i++) {
        sum += va_arg(args, int);
    }
    
    va_end(args);
    return sum / count;
}

// Usage:
// average(4, 10, 20, 30, 40) returns 25.0

Given:

struct Point p1 = {1, 2};           // Line A
struct Point *p2 = &p1;             // Line B
printPoint((struct Point){3, 4});   // Line C
struct Point p3;                    // Line D
p3.x = 5; p3.y = 6;                 // Line E

Task: Which line uses a compound literal?

Show Solution
// Line C uses a compound literal.
// (struct Point){3, 4} creates an unnamed struct Point 
// object that's passed directly to the function.

Given:

double arr[] = {3.14, 1.41, 2.71, 1.73};

Task: Write a comparison function to sort doubles in descending order.

Show Solution
int compareDoubles(const void *a, const void *b) {
    double da = *(const double*)a;
    double db = *(const double*)b;
    
    // For descending order, return negative when a > b
    if (da > db) return -1;
    if (da < db) return 1;
    return 0;
}

// Usage:
double arr[] = {3.14, 1.41, 2.71, 1.73};
qsort(arr, 4, sizeof(double), compareDoubles);
// Result: {3.14, 2.71, 1.73, 1.41}
06

Common Mistakes and Best Practices

Even experienced programmers make mistakes with function parameters. This section covers the most common pitfalls and provides guidelines for writing clean, safe, and maintainable code.

6.1 Common Mistakes

Mistake #1: Forgetting to Dereference Pointers

❌ Wrong:

void increment(int *p) {
    p = p + 1;  // Changes local copy of pointer!
}

int main() {
    int x = 5;
    increment(&x);
    printf("%d\n", x);  // Still 5!
}

✓ Correct:

void increment(int *p) {
    *p = *p + 1;  // Dereference to modify value
}

int main() {
    int x = 5;
    increment(&x);
    printf("%d\n", x);  // Now 6!
}
Mistake #2: Returning Pointer to Local Variable

❌ Wrong:

int* createNumber() {
    int num = 42;
    return #  // DANGER! num doesn't exist
                  // after function returns
}

int main() {
    int *p = createNumber();
    printf("%d\n", *p);  // Undefined behavior!
}

✓ Correct:

int* createNumber() {
    int *num = malloc(sizeof(int));
    *num = 42;
    return num;  // Heap memory persists
}

int main() {
    int *p = createNumber();
    printf("%d\n", *p);  // 42
    free(p);  // Don't forget to free!
}
Mistake #3: Using sizeof() on Array Parameters

❌ Wrong:

void printSize(int arr[]) {
    // arr is actually a pointer here!
    int size = sizeof(arr) / sizeof(arr[0]);
    printf("Size: %d\n", size);  // Wrong!
}

int main() {
    int nums[10] = {0};
    printSize(nums);  // Prints 1 or 2, not 10!
}

✓ Correct:

void printSize(int arr[], int size) {
    printf("Size: %d\n", size);
}

int main() {
    int nums[10] = {0};
    int size = sizeof(nums) / sizeof(nums[0]);
    printSize(nums, size);  // Prints 10
}
Mistake #4: Not Checking for NULL

❌ Wrong:

void processData(int *data, int size) {
    for (int i = 0; i < size; i++) {
        data[i] *= 2;  // Crash if data is NULL!
    }
}

int main() {
    processData(NULL, 5);  // CRASH!
}

✓ Correct:

void processData(int *data, int size) {
    if (data == NULL || size <= 0) {
        return;  // Defensive programming
    }
    for (int i = 0; i < size; i++) {
        data[i] *= 2;
    }
}

int main() {
    processData(NULL, 5);  // Safe, does nothing
}
Mistake #5: Modifying const Parameters

❌ Wrong:

void uppercase(const char *str) {
    for (int i = 0; str[i]; i++) {
        str[i] = toupper(str[i]);  // Error!
    }
}

✓ Correct:

void uppercase(char *str) {  // Remove const
    for (int i = 0; str[i]; i++) {
        str[i] = toupper(str[i]);  // OK
    }
}

// Or create a copy:
char* uppercaseCopy(const char *str) {
    char *result = strdup(str);
    // ... modify result
    return result;
}

6.2 Best Practices Summary

DO
  • Use const for read-only parameters
  • Always pass array size as a separate parameter
  • Check pointers for NULL before dereferencing
  • Use meaningful parameter names
  • Document what each parameter does
  • Prefer passing small structs by value
  • Use pointers for large structs (>16 bytes)
  • Initialize output parameters to safe defaults
  • Return status codes for error handling
  • Use typedef for complex function pointer types
DON'T
  • Return pointers to local variables
  • Use sizeof() on array parameters
  • Modify const parameters (cast away const)
  • Use uninitialized pointer parameters
  • Ignore compiler warnings about parameters
  • Pass too many parameters (max 4-5)
  • Mix input and output parameters randomly
  • Forget to free() dynamically allocated return values
  • Use global variables when parameters work
  • Rely on parameter order instead of names

6.3 Parameter Count Guidelines

Parameters Recommendation Action
0-3 Ideal Keep as is
4-5 Acceptable Consider grouping related params
6+ Too many Refactor using structs or split function

Refactoring Too Many Parameters

Before (Too Many Parameters):

void createWindow(
    int x, int y, 
    int width, int height,
    const char *title,
    int borderStyle,
    int bgColor,
    int fgColor,
    int flags
) {
    // Complex implementation
}

After (Using Struct):

struct WindowConfig {
    int x, y;
    int width, height;
    const char *title;
    int borderStyle;
    int bgColor, fgColor;
    int flags;
};

void createWindow(struct WindowConfig cfg) {
    // Same implementation, cleaner API
}

// Usage with designated initializers:
createWindow((struct WindowConfig){
    .width = 800, .height = 600,
    .title = "My App"
});

Practice Questions: Best Practices

Test your understanding of parameter best practices.

Given:

char* getName() {
    char name[50];
    scanf("%s", name);
    return name;
}

Task: Identify the bug and explain how to fix it.

Show Solution
// Bug: Returning pointer to local array `name`.
// The array is destroyed when the function returns,
// leading to undefined behavior.

// Fix 1: Use malloc
char* getName() {
    char *name = malloc(50);
    scanf("%s", name);
    return name;
}

// Fix 2: Pass buffer as parameter
void getName(char *buffer, int size) {
    fgets(buffer, size, stdin);
}

Given:

void process(const int *arr, int size) {
    for (int i = 0; i < size; i++) {
        arr[i] = arr[i] * 2;
    }
}

Task: Explain the compilation error and how to fix it.

Show Solution
// Error: Cannot modify `arr[i]` because `arr` 
// points to const int. The `const` keyword means "read-only".

// Fix: Remove const if you need to modify
void process(int *arr, int size) {
    for (int i = 0; i < size; i++) {
        arr[i] = arr[i] * 2;  // OK now
    }
}

Given:

void mystery(int *p) {
    int x = 100;
    p = &x;
    *p = 200;
}

int main() {
    int num = 50;
    mystery(&num);
    printf("%d\n", num);
}

Task: Predict the output and explain why.

Show Solution
// Output: 50

// Explanation: The function receives a copy of the pointer.
// When `p = &x` executes, only the local copy changes 
// to point to `x`. The original `num` is never modified.

Given:

void sendEmail(char *to, char *from, char *subject, 
               char *body, int priority, int encrypt, 
               int html, int attachCount, char **attachments);

Task: Refactor this function to use a better parameter design.

Show Solution
// Better: Use a configuration struct
struct EmailConfig {
    const char *to;
    const char *from;
    const char *subject;
    const char *body;
    int priority;
    int encrypt;      // Could use bool
    int html;         // Could use bool
    int attachCount;
    const char **attachments;
};

void sendEmail(struct EmailConfig config);

// Usage:
sendEmail((struct EmailConfig){
    .to = "user@example.com",
    .from = "me@example.com",
    .subject = "Hello",
    .body = "Hi there!",
    .priority = 1
    // Other fields default to 0/NULL
});

Master Reference Table

This comprehensive reference table summarizes all parameter passing methods in C. Use this as a quick lookup when deciding which approach to use in your programs.

Method Syntax Inside Function Original Modified? Use Case
Pass by Value void f(int x) x = value No Small data, read-only access
Pass by Pointer void f(int *x) *x = value Yes Modify caller's variable
Pass Array void f(int arr[], int n) arr[i] = value Yes Process collections
Pass 2D Array void f(int arr[][N], int rows) arr[i][j] = value Yes Matrices, tables
Pass String void f(char *str) str[i] = 'c' Yes Text processing
Pass Struct (value) void f(struct S s) s.field = value No Small structs (<16 bytes)
Pass Struct (pointer) void f(struct S *s) s->field = value Yes Large structs, modifications
Pass const Pointer void f(const int *x) // read only No (protected) Read large data efficiently
Function Pointer void f(int (*op)(int)) result = op(x) N/A Callbacks, strategies
Variadic void f(int n, ...) va_arg(args, int) Varies Variable argument count

Quick Decision Guide

Need to Modify Original?
  • YES → Use pointers (int *x)
  • NO → Use value (int x) or const pointer
Is Data Large (>16 bytes)?
  • YES → Use pointer (avoids expensive copy)
  • NO → Either approach is fine
Is It an Array?
  • YES → Always pass pointer + size
  • Arrays automatically decay to pointers
Need to Protect Data?
  • YES → Use const modifier
  • Compiler will catch accidental modifications

Real-World Code Patterns

Pattern 1: Output Parameter

// Function returns status, modifies result via pointer
int divide(int a, int b, int *result) {
    if (b == 0) {
        return -1;  // Error: division by zero
    }
    *result = a / b;
    return 0;  // Success
}

// Usage:
int quotient;
if (divide(10, 2, "ient) == 0) {
    printf("Result: %d\n", quotient);  // Result: 5
} else {
    printf("Error!\n");
}

Pattern 2: In-Place Array Modification

// Modify array elements in place
void doubleAll(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        arr[i] *= 2;
    }
}

// Usage:
int numbers[] = {1, 2, 3, 4, 5};
doubleAll(numbers, 5);
// numbers is now {2, 4, 6, 8, 10}

Pattern 3: Swap Values

// Classic swap using pointers
void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

// Generic swap for any type (advanced)
void genericSwap(void *a, void *b, size_t size) {
    char temp[size];  // VLA (C99)
    memcpy(temp, a, size);
    memcpy(a, b, size);
    memcpy(b, temp, size);
}

Pattern 4: Returning Multiple Values

// Find min and max in array
void findMinMax(const int arr[], int size, int *min, int *max) {
    if (size <= 0) return;
    
    *min = *max = arr[0];
    for (int i = 1; i < size; i++) {
        if (arr[i] < *min) *min = arr[i];
        if (arr[i] > *max) *max = arr[i];
    }
}

// Usage:
int data[] = {5, 2, 8, 1, 9, 3};
int minVal, maxVal;
findMinMax(data, 6, &minVal, &maxVal);
printf("Min: %d, Max: %d\n", minVal, maxVal);  // Min: 1, Max: 9

Pattern 5: Callback for Custom Behavior

// Apply any operation to array elements
typedef int (*Operation)(int);

void applyToAll(int arr[], int size, Operation op) {
    for (int i = 0; i < size; i++) {
        arr[i] = op(arr[i]);
    }
}

// Callback functions
int square(int x) { return x * x; }
int negate(int x) { return -x; }
int increment(int x) { return x + 1; }

// Usage:
int nums[] = {1, 2, 3, 4, 5};
applyToAll(nums, 5, square);     // {1, 4, 9, 16, 25}
applyToAll(nums, 5, negate);     // {-1, -4, -9, -16, -25}
applyToAll(nums, 5, increment);  // {0, -3, -8, -15, -24}

Interactive Demos

Explore these interactive demonstrations to visualize how parameter passing works in C. Adjust values and see the results in real-time to solidify your understanding.

Pass by Value vs Reference Simulator

Enter a value and see how it behaves when passed by value (copy) versus by reference (pointer).

This is your variable in main()
Amount added inside function
Pass by Value
void addValue(int num) {
    num = num + 10;  // Modifies COPY
}

int main() {
    int x = 42;
    addValue(x);
    // x is still 42
}
Before call: x = 42
Inside function: num = 52
After call: x = 42
Pass by Reference (Pointer)
void addRef(int *ptr) {
    *ptr = *ptr + 10;  // Modifies ORIGINAL
}

int main() {
    int x = 42;
    addRef(&x);
    // x is now 52
}
Before call: x = 42
Inside function: *ptr = 52
After call: x = 52

Memory Address Visualizer

See how variables and pointers are stored in memory. Click on memory cells to understand the relationship.

Stack Memory Layout
0x1000 x (int) 42
0x1004 ptr (int*) 0x1000
0x1008 arr[0] 10
0x100C arr[1] 20
0x1010 arr[2] 30
Pointer Operations
int x = 42; Variable x stores value 42 at address 0x1000
int *ptr = &x; Pointer ptr stores address 0x1000
*ptr = 100; Dereference: Go to address 0x1000 and change value
Key Concepts:
  • &x - Address-of operator (get address)
  • *ptr - Dereference operator (get value at address)
  • Pointers are variables that store memory addresses
  • Changing *ptr changes the value at that address

Key Takeaways

Pass by Value

By default, C passes function arguments by value, meaning the function receives a copy and cannot modify the original variable.

Using Pointers

Passing pointers allows functions to modify variables in the caller by accessing their memory addresses.

Arrays as Parameters

When an array is passed to a function, it decays into a pointer to its first element - array size must be passed separately.

const Safety

Using const in function parameters prevents accidental modification and improves code safety and readability.

restrict Optimization

The restrict keyword (C99) allows the compiler to optimize pointer-based code when aliasing is guaranteed not to occur.

Pointer Risks

Improper pointer usage can lead to crashes - always validate pointers before dereferencing them.

Knowledge Check

Quick Quiz

Test your understanding of Function Parameters in C

1 What is the default parameter passing method in C?
2 Which keyword prevents a function from modifying an input array?
3 What does passing an array to a function actually pass?
4 What is the main risk of using pointers in function parameters?
5 Which modifier allows the compiler to optimize pointer usage in C99?
6 What is the best way to allow a function to update a variable in main?
0/6 answered