Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 16

Passing Structs to Functions

Recommended resources

Introduction

Hello! In our last lesson, we learned how to define and use structs to group related data, creating our own custom data types like the Note struct for your music project. We saw how to define them, access members with the . operator, and initialize them.

Today, we'll connect this to what we've learned about functions and pointers. The central question is: what happens when we pass a complex data type like a struct to a function? This lesson directly addresses the learning outcome: Pass structs to functions by value and by pointer.

We'll explore two fundamental mechanisms for this and their crucial differences:

  1. Pass-by-value: Sending a complete copy of the data.
  2. Pass-by-pointer: Sending the memory address of the data.

Understanding the trade-offs between these two methods is vital for writing efficient and correct C code, especially in performance-sensitive areas like automation and game development. This choice impacts performance, memory usage, and whether a function can modify the original data.


1. The Default: Pass-by-Value

In C, when you pass a struct variable to a function, the default behavior is pass-by-value. The function receives a complete, independent copy of the original struct. Any modifications the function makes are to its local copy, leaving the original variable untouched.

Think of it like making a photocopy of a document. If you give someone the photocopy, any notes they write on it won't appear on your original.

CS31: Intro to C Structs and Pointers

This article from Swarthmore College's CS department gives a very clear and direct explanation of this concept.

Read the first section titled 'Passing structs to functions'. Focus on the core idea: the function receives a COPY of the struct, and changes within the function are not reflected in the original.

Let's see this in action. Here is a simple Point struct:




#include <stdio.h>

typedef struct {
    int x;
    int y;
} Point;

// This function receives a COPY of the point.
void modify_and_print(Point p) {
    printf("Inside function (start): p = (%d, %d)\n", p.x, p.y);
    // Modify the local copy
    p.x = 100;
    p.y = 200;
    printf("Inside function (end):   p = (%d, %d)\n", p.x, p.y);
}

int main() {
    Point my_point = {10, 20};
    printf("Before function call: my_point = (%d, %d)\n", my_point.x, my_point.y);
    
    modify_and_print(my_point);
    
    printf("After function call:  my_point = (%d, %d)\n", my_point.x, my_point.y);
    return 0;
}

When you compile and run this, you'll see that my_point in main remains unchanged.

Pros and Cons of Pass-by-Value:

  • Pro: It's safe. The function cannot accidentally change your original data.
  • Con: It can be inefficient. If the struct is large (e.g., containing large arrays or many fields), copying all that data onto the function's stack frame takes time and memory.

2. The Efficient Alternative: Pass-by-Pointer

To avoid the cost of copying, we can pass a pointer to the struct instead. This is C's way of simulating pass-by-reference. Instead of sending the whole data structure, we only send its memory address—a small, fixed-size value (usually 8 bytes on a 64-bit system).

When a function has a pointer to the original struct, it can read from and—crucially—write to the original data.

C Struct Memory Layout and Pointer Reference
This image illustrates the core concept. The `ptr` variable doesn't hold the struct's data itself, but rather the memory address (e.g., 2000) where the `std` struct begins.

When working with a pointer to a struct, we use the arrow operator -> to access its members, instead of the dot operator .. The expression sptr->member is simply syntactic sugar for (*sptr).member.

Master Pointers in C: 10X Your C Coding!

The video 'Master Pointers in C' by Dave's Garage offers a great analogy and a clear, concise explanation of passing structs by pointer and the arrow operator.

Watch the segment from 04:03 to 06:27. Pay close attention to: The distinction between the dot . and arrow -> operators. The 'house appraiser' analogy for why passing an address is more efficient. The definition of pass-by-value vs. pass-by-reference.

Making Pass-by-Pointer Safe with const

A common use case is when a function only needs to read a struct, not modify it. We can enforce this by using the const keyword. This gives us the best of both worlds: the efficiency of pass-by-pointer and the safety of pass-by-value.

// The 'const' promises that this function will not change the data 'p' points to.
void print_point_by_ptr(const Point *p) {
    // We can read the members.
    printf("Point = (%d, %d)\n", p->x, p->y);

    // If you uncomment the line below, the compiler will produce an error.
    // p->x = 100; // ERROR: assignment of read-only location
}

This is a very important and widely used pattern in C programming.


3. The Performance Nuance: When is Value Better?

The standard advice is: "Pass small structs by value, large structs by pointer." But the reality, as is often the case in systems programming, is more nuanced. Given your interests in math-heavy fields and "under the hood" operations, this is a trade-off you'll find fascinating.

A modern compiler is an incredibly sophisticated piece of software. When you pass a small struct by value, the compiler can often optimize the code to be even faster than passing a pointer by keeping all the data in CPU registers, avoiding slower memory access entirely.

Pass by Value vs Pass by Pointer

The article 'Pass by Value vs Pass by Pointer' by Austin Morlan does a fantastic job of analyzing this trade-off by looking at the machine code the compiler generates. This is a deep dive, but the conclusions are highly practical.

I encourage you to read through this article. You don't need to understand every line of assembly code. Focus on: The two C code examples for vec4_add: one using pointers and one using values. The 'Pass by Pointer (-O2)' and 'Pass by Value (-O2)' sections for the 'Small Struct' to see how few instructions are needed with optimizations. The same optimized sections for the 'Large Struct' (mat4) to see how the pattern changes. Read the 'Conclusion' section carefully. It summarizes the trade-offs regarding performance, readability, and potential compiler optimizations.

Key Takeaway from the Analysis:

  • For small structs (like a 2D/3D vector), pass-by-value is often preferable. It can be faster due to compiler optimizations and leads to more readable code (e.g., c = add(a, b)).
  • For large structs, the cost of copying the data outweighs any register optimization benefits. Pass-by-pointer is the clear winner for performance. The const keyword should be used if the data is read-only.

4. "Returning" Structs from Functions

Now, how do we get a struct out of a function? There are a few ways, each with different implications.

How to return and pass a struct to a function

The CodeVault video we'll watch now walks through the different strategies for returning a struct, highlighting a major pitfall and showing the most common C idiom.

Watch the video from 01:26 to 07:42, and then from 09:52 to 12:41. Pay attention to the following progression: Returning by Value (01:26 - 04:22): The function returns a full copy of the struct. Simple, but involves copying. A Dangerous Idea (05:53 - 07:42): The video shows why you must never return a pointer to a local variable. That memory is on the stack and becomes invalid as soon as the function returns. The Output Parameter Idiom (09:52 - 12:41): This is the most efficient and idiomatic C approach for 'returning' large structs. The caller provides the memory, and the function fills it in. Notice how this avoids both unnecessary copying and the dangling pointer problem.

To summarize the output parameter pattern:

// This function 'returns' a Point by writing into the memory location
// provided by the caller via the 'out_p' pointer.
void create_point(int x, int y, Point *out_p) {
    out_p->x = x;
    out_p->y = y;
}

int main() {
    Point new_point; // The 'main' function allocates the memory on its stack.
    
    // We pass the address of 'new_point' to the function to be filled.
    create_point(50, 60, &new_point);
    
    printf("Created point: (%d, %d)\n", new_point.x, new_point.y);
}

This pattern is extremely common in C libraries because it's efficient and gives the caller control over memory allocation.


Practical Challenge

Let's apply these techniques to the Note struct from our last lesson.

Your task:

  1. Start with the Note struct and typedef from the previous lesson.
  2. Write a function void print_note(const Note *note) that takes a constant pointer to a Note and prints its members in a formatted way.
  3. Write a function void transpose_note(Note *note, int semitones) that modifies the note's pitch. For simplicity, just add the semitones value to the character representing the note letter (e.g., 'G' + 2 would become 'I'). This is just a placeholder for a real music logic function, the goal is to practice modification.
  4. Write a function void create_rest_note(Note *output_note) that uses the output parameter pattern to create a "rest" note (e.g., pitch "---", duration 1.0, velocity 0).
  5. In main, create a note, print it, transpose it, print it again, and then create a rest note and print that.
Click here for a sample solution



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

typedef struct {
    char pitch[4];
    float duration;
    int velocity;
} Note;

// 2. Print function (read-only access)
void print_note(const Note *note) {
    printf("Note(Pitch: %s, Duration: %.2f, Velocity: %d)\n",
           note->pitch, note->duration, note->velocity);
}

// 3. Modification function
void transpose_note(Note *note, int semitones) {
    // A simplified 'transpose' for demonstration
    if (note->pitch[0] != '-') {
        note->pitch[0] += semitones;
    }
}

// 4. Output parameter function
void create_rest_note(Note *output_note) {
    strcpy(output_note->pitch, "---");
    output_note->duration = 1.0f;
    output_note->velocity = 0;
}

int main() {
    // 5. Putting it all together
    Note my_note = {"C4", 0.5f, 100};
    
    printf("Original note: ");
    print_note(&my_note);

    transpose_note(&my_note, 2);
    printf("Transposed note: ");
    print_note(&my_note);

    Note rest;
    create_rest_note(&rest);
    printf("Rest note: ");
    print_note(&rest);

    return 0;
}

Conclusion

Today you've mastered a critical concept: how to handle structs in functions. This knowledge is essential for building any non-trivial C program.

Key Takeaways:

  • Pass-by-value copies the entire struct. It's safe but can be slow for large structs.
  • Pass-by-pointer passes only a memory address. It's efficient and allows the function to modify the original data.
  • Use const with pointers (const MyStruct *s) to get the efficiency of pointers with the safety of preventing modification.
  • For small structs, passing by value can be faster due to compiler optimizations and may lead to more readable code.
  • The output parameter pattern (void func(MyStruct *out)) is the standard, efficient C idiom for having a function "return" a large data structure.

Next Up

We've used typedef as a convenience to simplify our struct declarations. In the next lesson, we'll explore its power more broadly. We will see how it can dramatically improve code readability and maintainability when working with more complex types, setting the stage for creating more modular and scalable programs.