Structures
From the design perspective, structures are one of the most fundamental concepts in C. Nowadays, they are not unique to C, and you can find their twin concepts nearly in every modern programming language.
But we should discuss them in the history of computation when there were no other programming languages offering such a concept. Among many efforts to move away from machine-level programming languages, introducing structures was a great step toward having encapsulation in a programming language. For thousands of years, the way we think hasn't changed a lot, and encapsulation has been a centric means for our logical reasoning.
But it was just after C that we finally had some tool, in this case, a programming language, which was able to understand the way we think and could store and process the building blocks of our reasoning. Finally, we got a language that resembles our thoughts and ideas, and all of this happened when we got structures. C structures weren't perfect in comparison to the encapsulation mechanisms found in modern languages, but they were enough for us to build a platform upon which to create our finest tools.
Why structures?
You know that every programming language has some Primitive Data Types (PDTs). Using these PDTs, you can design your data structures and write your algorithms around them. These PDTs are part of the programming language, and they cannot be changed or removed. As an example, you cannot have C without the primitive types, int
and double
.
Structures come into play when you need to have your own defined data types, and the data types in the language are not enough. User-Defined Types (UDTs) are those types which are created by the user and they are not part of the language.
Note that UDTs are different from the types you could define using typedef
. The keyword typedef
doesn't really create a new type, but rather it defines an alias or synonym for an already defined type. But structures allow you to introduce totally new UDTs into your program.
Structures have twin concepts in other programming languages, for example, classes in C++ and Java or packages in Perl. They are considered to be the type-makers in these languages.
Why user-defined types?
So, why do we need to create new types in a program? The answer to this question reveals the principles behind software design and the methods we use for our daily software development. We create new types because we do it every day using our brains in a routine analysis.
We don't look at our surroundings as integers, doubles, or characters. We have learned to group related attributes under the same object. We will discuss more the way we analyze our surroundings in Chapter 6, OOP and Encapsulation. But as an answer to our starting question, we need new types because we use them to analyze our problems at a higher level of logic, close enough to our human logic.
Here, you need to become familiar with the term business logic. Business logic is a set of all entities and regulations found in a business. For example, in the business logic of a banking system, you face concepts such as client, account, balance, money, cash, payment, and many more, which are there to make operations such as money withdrawal possible and meaningful.
Suppose that you had to explain some banking logic in pure integers, floats, or characters. It is almost impossible. If it is possible for programmers, it is almost meaningless to business analysts. In a real software development environment that has a well-defined business logic, programmers and business analysts cooperate closely. Therefore, they need to have a shared set of terminology, glossary, types, operations, regulations, logic, and so on.
Today, a programming language that does not support new types in its type system can be considered as a dead language. Maybe that's why most people see C as a dead programming language, mainly because they cannot easily define their new types in C, and they prefer to move to a higher-level language such as C++ or Java. Yes, it's not that easy to create a nice type system in C, but everything you need is present there.
Even today, there can be many reasons behind choosing C as the project's main language and accepting the efforts of creating and maintaining a nice type system in a C project and even today many companies do that.
Despite the fact that we need new types in our daily software analysis, CPUs do not understand these new types. CPUs try to stick to the PDTs and fast calculations because they are designed to do that. So, if you have a program written in your high-level language, it should be translated to CPU level instructions, and this may cost you more time and resources.
In this sense, fortunately, C is not very far away from the CPU-level logic, and it has a type system which can be easily translated. You may have heard that C is a low-level or hardware-level programming language. This is one of the reasons why some companies and organizations try to write and maintain their core frameworks in C, even today.
What do structures do?
Structures encapsulate related values under a single unified type. As an early example, we can group red
, green
, and blue
variables under a new single data type called color_t
. The new type, color_t
, can represent an RGB color in various programs like an image editing application. We can define the corresponding C structure as follows:
struct color_t { int red; int green; int blue; };
Code Box 1-33: A structure in C representing an RGB color
As we said before, structures do encapsulation. Encapsulation is one of the most fundamental concepts in software design. It is about grouping and encapsulating related fields under a new type. Then, we can use this new type to define the required variables. We will describe encapsulation thoroughly in Chapter 6, OOP and Encapsulation, while talking about object-oriented design.
Note that we use an _t
suffix for naming new data types.
Memory layout
It is usually important to C programmers to know exactly the memory layout of a structure variable. Having a bad layout in memory could cause performance degradations in certain architectures. Don't forget that we code to produce the instructions for the CPU. The values are stored in the memory, and the CPU should be able to read and write them fast enough. Knowing the memory layout helps a developer to understand the way the CPU works and to adjust their code to gain a better result.
The following example, example 1.21, defines a new structure type, sample_t
, and declares one structure variable, var
. Then, it populates its fields with some values and prints the size and the actual bytes of the variable in the memory. This way, we can observe the memory layout of the variable:
#include <stdio.h> struct sample_t { char first; char second; char third; short fourth; }; void print_size(struct sample_t* var) { printf("Size: %lu bytes\n", sizeof(*var)); } void print_bytes(struct sample_t* var) { unsigned char* ptr = (unsigned char*)var; for (int i = 0; i < sizeof(*var); i++, ptr++) { printf("%d ", (unsigned int)*ptr); } printf("\n"); } int main(int argc, char** argv) { struct sample_t var; var.first = 'A'; var.second = 'B'; var.third = 'C'; var.fourth = 765; print_size(&var); print_bytes(&var); return 0; }
Code Box 1-34 [ExtremeC_examples_chapter1_21.c]: Printing the number of the bytes allocated for a structure variable
The thirst to know the exact memory layout of everything is a bit C/C++ specific, and vanishes as the programming languages become high level. For example, in Java and Python, the programmers tend to know less about the very low-level memory management details, and on the other hand, these languages don't provide many details about the memory.
As you see in Code Box 1-34, in C, you have to use the struct
keyword before declaring a structure variable. Therefore, in the preceding example we have struct sample_t var
, which shows how you should use the keyword before the structure type in the declaration clause. It is trivial to mention that you need to use a .
(dot) to access the fields of a structure variable. If it is a structure pointer, you need to use ->
(arrow) to access its fields.
In order to prevent typing a lot of struct
s throughout the code, while defining a new structure type and while declaring a new structure variable, we could use typedef
to define a new alias type for the structure. Following is an example:
typedef struct { char first; char second; char third; short fourth; } sample_t;
Now, you can declare the variable without using the keyword struct
:
sample_t var;
The following is the output of the preceding example after being compiled and executed on a macOS machine. Note that the numbers generated may vary depending upon the host system:
$ clang ExtremeC_examples_chapter1_21.c $ ./a.out Size: 6 bytes 65 66 67 0 253 2 $
Shell Box 1-13: Output of example 1.21
As you see in the preceding shell box, sizeof(sample_t)
has returned 6 bytes. The memory layout of a structure variable is very similar to an array. In an array, all elements are adjacent to each other in the memory, and this is the same for a structure variable and its field. The difference is that, in an array, all elements have the same type and therefore the same size, but this is not the case regarding a structure variable. Each field can have a different type, and hence, it can have a different size. Unlike an array, the memory size of which is easily calculated, the size of a structure variable in the memory depends on a few factors and cannot be easily determined.
At first, it seems to be easy to guess the size of a structure variable. For the structure in the preceding example, it has four fields, three char
fields, and one short
field. With a simple calculation, if we suppose that sizeof(char)
is 1 byte and sizeof(short)
is 2 bytes, each variable of the type sample_t
should have 5 bytes in its memory layout. But when we look at the output, we see that sizeof(sample_t)
is 6 bytes. 1 byte more! Why do we have this extra byte? Again, while looking at the bytes in the memory layout of the structure variable, var
, we can see that it is a bit different from our expectation which is 65 66 67 253 2
.
For making this clearer and explaining why the size of the structure variable is not 5 bytes, we need to introduce the memory alignment concept. The CPU always does all the computations. Besides that, it needs to load values from memory before being able to compute anything and needs to store the results back again in the memory after a computation. Computation is super-fast inside the CPU, but the memory access is very slow in comparison. It is important to know how the CPU interacts with the memory because then we can use the knowledge to boost a program or debug an issue.
The CPU usually reads a specific number of bytes in each memory access. This number of bytes is usually called a word. So, the memory is split into words and a word is an atomic unit used by the CPU to read from and write to the memory. The actual number of bytes in a word is an architecture-dependent factor. For example, in most 64-bit machines, the word size is 32 bits or 4 bytes. Regarding the memory alignment, we say that a variable is aligned in the memory if its starting byte is at the beginning of a word. This way, the CPU can load its value in an optimized number of memory accesses.
Regarding the previous example, example 1.21, the first 3 fields, first
, second
, and third
, are 1 byte each, and they reside in the first word of the structure's layout, and they all can be read by just one memory access. About the fourth field, fourth
occupies 2 bytes. If we forget about the memory alignment, its first byte will be the last byte of the first word, which makes it unaligned.
If this was the case, the CPU would be required to make two memory accesses together with shifting some bits in order to retrieve the value of the field. That is why we see an extra zero after byte 67
. The zero byte has been added in order to complete the current word and let the fourth field start in the next word. Here, we say that the first word is padded by one zero byte. The compiler uses the padding technique to align values in the memory. Padding is the extra bytes added to match the alignment.
It is possible to turn off the alignment. In C terminology, we use a more specific term for aligned structures. We say that the structure is not packed. Packed structures are not aligned and using them may lead to binary incompatibilities and performance degradation. You can easily define a structure that is packed. We will do it in the next example, example 1.22, which is pretty similar to the previous example, example 1.21. The sample_t
structure is packed in this example. The following code box shows example 1.22. Note that the similar code are replaced by ellipses:
#include <stdio.h> struct __attribute__((__packed__)) sample_t { char first; char second; char third; short fourth; } ; void print_size(struct sample_t* var) { // ... } void print_bytes(struct sample_t* var) { // ... } int main(int argc, char** argv) { // ... }
Code Box 1-35 [ExtremeC_examples_chapter1_22.c]: Declaring a packed structure
In the following shell box, the preceding code is compiled using clang
and run on macOS:
$ clang ExtremeC_examples_chapter1_22.c $ ./a.out Size: 5 bytes 65 66 67 253 2 $
Shell Box 1-14: Output of example 1.22
As you see in Shell Box 1-14, the printed size is exactly what we were expecting as part of example 1.21. The final layout is also matched with our expectation. Packed structures are usually used in memory-constrained environments, but they can have a huge negative impact on the performance on most architectures. Only new CPUs can handle reading an unaligned value from multiple words without enforcing extra cost. Note that memory alignment is enabled by default.
Nested structures
As we have explained in the previous sections, in general, we have two kinds of data types in C. There are the types that are primitive to the language and there are types which are defined by the programmers using the struct
keyword. The former types are PDTs, and the latter are UDTs.
So far, our structure examples have been about UDTs (structures) made up of only PDTs. But in this section, we are going to give an example of UDTs (structures) that are made from other UDTs (structures). These are called complex data types, which are the result of nesting a few structures.
Let's begin with the example, example 1.23:
typedef struct { int x; int y; } point_t; typedef struct { point_t center; int radius; } circle_t; typedef struct { point_t start; point_t end; } line_t;
Code Box 1-36 [ExtremeC_examples_chapter1_23.c]: Declaring some nested structures
In the preceding code box, we have three structures; point_t
, circle_t
, and line_t
. The point_t
structure is a simple UDT because it is made up of only PDTs, but other structures contain a variable of the point_t
type, which makes them complex UDTs.
The size of a complex structure is calculated exactly the same as a simple structure, by summing up the sizes of all its fields. We should be still careful about the alignment, of course, because it can affect the size of a complex structure. So, sizeof(point_t)
would be 8 bytes if sizeof(int)
is 4 bytes. Then, sizeof(circle_t)
is 12 bytes and sizeof(line_t)
is 16 bytes.
It is common to call structure variables objects. They are exactly analogous to objects in object-oriented programming, and we will see that they can encapsulate both values and functions. So, it is not wrong at all to call them C objects.
Structure pointers
Like pointers to PDTs, we can have pointers to UDTs as well. They work exactly the same as PDT pointers. They point to an address in memory, and you can do arithmetic on them just like with the PDT pointers. UDT pointers also have arithmetic step sizes equivalent to the size of the UDT. If you don't know anything about the pointers or the allowed arithmetic operations on them, please go to the Pointers section and give it a read.
It is important to know that a structure variable points to the address of the first field of the structure variable. In the previous example, example 1.23, a pointer of type point_t
would point to the address of its first field, x
. This is also true for the type, circle_t
. A pointer of type circle_t
would point to its first field, center
, and since it is actually a point_t
object, it would point to the address of the first field, x
, in the point_t
type. Therefore, we can have 3 different pointers addressing the same cell in the memory. The following code will demonstrate this:
#include <stdio.h> typedef struct { int x; int y; } point_t; typedef struct { point_t center; int radius; } circle_t; int main(int argc, char** argv) { circle_t c; circle_t* p1 = &c; point_t* p2 = (point_t*)&c; int* p3 = (int*)&c; printf("p1: %p\n", (void*)p1); printf("p2: %p\n", (void*)p2); printf("p3: %p\n", (void*)p3); return 0; }
Code Box 1-37 [ExtremeC_examples_chapter1_24.c]: Having three different pointers from three different types addressing the same byte in memory
And this is the output:
$ clang ExtremeC_examples_chapter1_24.c $ ./a.out p1: 0x7ffee846c8e0 p2: 0x7ffee846c8e0 p3: 0x7ffee846c8e0 $
Shell Box 1-15: Output of example 1.24
As you see, all of the pointers are addressing the same byte, but their types are different. This is usually used to extend structures coming from other libraries by adding more fields. This is also the way we implement inheritance in C. We will discuss this in Chapter 8, Inheritance and Polymorphism.
This was the last section in this chapter. In the upcoming chapter, we will dive into the C compilation pipeline and how to properly compile and link a C project.