Introduction
Last time we went over pointers. We will use them today together with structs.
So far we’ve only used the basic data types like char and int. But what if we want to use our own abstract data type, like list or hash table? What if we want to have a special collection of data grouped together, like the name and surname duplet?
Structs will make our program more structured and I will show you how to work with them in this lesson.
What is a struct?
A struct or structure is a collection of various data types (variables) that can be accessed independently. They are similar to classes in the OOP languages, but they lack many things compared to the usual classes (e.g. methods).
How to create one?
Let’s look at the example:
#include <stdio.h>
struct Person {
char *name;
char *surname;
int age;
};
void Person_print(struct Person person) {
printf("Hi, I'm %s %s and I am %d years old.\n",
person.name, person.surname, person.age);
}
int main() {
struct Person wec = {.name="Wec", .surname="ros", .age=20};
struct Person jdoe = {"John", "Doe", 32};
Person_print(wec);
Person_print(jdoe);
return 0;
}On lines 3-7 you can see the definition of the struct. It begins with the struct keyword followed by an identifier and curly brackets with listed variables.
The initialization happens on lines 15-16. You can either specify the name of the property prepended by a dot, or list the variables in a sequence.
To access the struct’s variables we use the variable name followed by a dot and the variable name, e.g.: person.name
I have prepared a little print funciton that works with the Person struct. Look at how the argument is passed into it. As the data type we use struct identifier pair. This can bloat the code a bit, so let’s see how we handle it with the typedef.
Typedef
With typedef we can assign complex data types a more simple name. Here we use it to get rid of the struct keyword in the data type’s name. Using typedef we can rewrite the previous example like this:
#include <stdio.h>
struct Person {
char *name;
char *surname;
int age;
};
typedef struct Person Person;
void Person_print(Person person) {
printf("Hi, I'm %s %s and I am %d years old.\n",
person.name, person.surname, person.age);
}
int main() {
Person wec = {.name="Wec", .surname="ros", .age=20};
Person jdoe = {"John", "Doe", 32};
Person_print(wec);
Person_print(jdoe);
return 0;
}Typedef first specifies the type definition (struct Person), after that the new named type (Person).
We can shorten the notation like this:
typedef struct Person {
char *name;
char *surname;
int age;
} Person;Stack vs Heap Allocation
Let’s talk about the two ways how we can initialize and store variables in C. Both are special regions in memory that the computer manages.
stack
Stack relates to the static memory allocation. Data here are ordered in a LIFO data structure. Temporary variables defined at the compile time are stored here, in sense everything that we’ve covered so far.
When the program enters a scope (e.g. function call) each local variable (arguments) are pushed onto the stack. When we leave the scope (function), the variables get popped off the stack. This prevents memory leaks.
heap
Heap relates to the dynamic memory allocation. Data here are not located in any order, the allocation happens at the run time. You are responsible for allocating and freeing your memory here. If you don’t free the allocated memory, you risk losing the memory in a memory leak.
Usually you don’t have to worry about dynamic memory allocation or deallocation in modern languages. The modern languages usually handle this in the background using constructs like garbage collection.
malloc
In C, you need to use malloc and free functions to allocate memory dynamically on the heap. Every time we use malloc, the OS reserves a chunk of memory for us and returns the pointer to it to us. When we no longer need the memory, we use free to tell the OS we no longer need that memory and that it can be used by other programs.
The concerns of using stack and heap
- When we use malloc, the pointer to that memory is kept traditionally on the stack. So when we exit the current scope the pointer gets popped off and we can no longer access the memory.
- When we use large data structures on the stack, we risk running into the stack overflow error.We also bloat the program by copying large data structures instead of passing just the pointer to them. That is why it is preferable to use malloc with structs.
- When you have a pointer to something on the stack and you return the pointer from a function, you get segmentation fault (segfault) when you try to access the pointer now, because it points to no-longer existing thing.
Because of the 2nd point, it is preferable to work with structs using pointers. At the end it even eases the work with them in my opinion.
To add one final point, when a program exits OS will clean up all the resources for you. It is however sometimes considered bad practice to not clean up the memory yourself, even when encountering error. To check if there are any memory leaks or any other memory problems, we use the program Valgrind.
Rewrite using dynamic memory
To use malloc, we specify the memory size in bytes in its argument. That is the memory we want to allocate. malloc returns the pointer to the start of this memory segment.
To use free, we pass the pointer to the function that we got from the return value of malloc. The OS does the rest for us.
Since malloc is usually used with structs, let’s rewrite our example.
#include <stdio.h>
#include <stdlib.h>
typedef struct Person {
char *name;
char *surname;
int age;
} Person;
void Person_print(Person *person) {
printf("Hi, I'm %s %s and I am %d years old.\n",
person->name, person->surname, person->age);
}
Person *Person_init(char *name, char *surname, int age) {
Person *person = malloc(sizeof(Person));
if (person == NULL) {
return NULL;
}
person->name = name;
person->surname = surname;
person->age = age;
return person;
}
void Person_free(Person *person) {
free(person);
}
int main() {
Person *wec = Person_init("Wec", "Ros", 20);
Person *jdoe = Person_init("Joe", "Doe", 32);
if (wec == NULL || jdoe == NULL) {
fprintf(stderr, "Allocation failed");
return 1; // error exit code
}
Person_print(wec);
Person_print(jdoe);
Person_free(wec);
Person_free(jdoe);
return 0;
}When working with pointers to a struct, to access the variables we use ->, instead of ., it is a shorthand for writing (*person).name.
You see that I created an init and free functions for the struct. It is more convenient to work with the Person in this way. Just note that we need to call Person_free after each Person_init call.
The size of the memory that we need is equal to sizeof(Person). Struct’s size is always equal to the sum of its variables.
As said before, it is good practice to check that the malloc did not fail. When malloc fails, the NULL pointer is returned. That is what we check for.
The rest of the code is the same as before. You might ask yourself, what is the benefit, this seems much more complicated than before! One of the reasons is that passing large data structures as function arguments is not preferable, as said before. Later on, when you embrace the pointers, this structure will look more pleasing and is quite scalable.
malloc without struct
Let’s see one more example before we part. I will not explain every small detail, so you can study on your own.
Let’s see how we use malloc in a situation when the size of the string depends on the user’s input. We cannot use the static array here like before. The size is simply not known when compiling. We need to use dynamic allocation and reallocation.
The program below wires the input as the output.
#include <stdio.h>
#include <stdlib.h>
int main() {
int size = 10; // initial size
int i = 0; // current index
int c; // character
char *str = malloc(sizeof(char) * size);
while ((c = getc(stdin)) != EOF) {
str[i] = c;
i++;
if (i >= size) {
size *= 2;
str = realloc(str, sizeof(char) * size);
}
}
str[i] = '\0';
printf("%s", str);
free(str);
return 0;
}getc reads one character from the standard input (discussed in more detail another time). We start with the base size of 10 characters and each time the size is not sufficient, we double the size.
The way we work with the string here is just a char array which size gets doubled when needed. That is why we need to add the '\0' as the final character. I hope you can see how realloc works by the example here.
Here is one of the possible outputs.
$ ./wire
Hi, I'm wiring this message as my output.<CTRL+D>
Hi, I'm wiring this message as my output.valgrind
Let’s try to break up the program with our analysis tool, valgrind. Run it like this:
$ valgrind ./wireThe valgrind should inform you that our program contains no memory leaks. Hooray!
Try to break it up. Remove the free() statement. Does the program run properly? What does Valgrind say? Remove the reallocation, how is the functionality, what are the errors that Valgrind complains about? Just getting the basic idea is all you need sometimes.
Try it for yourself! This is a valuable learning experience.
Conclusion
I hope that the structs and dynamic memory allocation examples were enough for you to comprehend. I always recommend writing your own little programs to test your knowledge.
You should be very well equipped now to write C programs of your own. You know the data types, every control statement, how to create arrays and strings, how to create functions, and how to pass the arguments to them. In addition, you can now create your own structures and work with memory dynamically, responding to the user’s needs.
You also know how to compile the C code, how to write a basic makefile for it, automating your workflow.
These are the basics of the C programming language that should guide you through the introductory university courses. In the next course, we will dive more into the practice of writing actual C programs. We will also create ourselves several useful abstract data types such as a list. We will learn about file streams, macros and how to efficiently debug your C program.
See you all there and happy programming! 😁