Learning C

16 minute read

So I’ve been spending some time this past week or so picking up a new language: C. C is considered by many to be one of the most basic and fundamental of all languages, not only because it is what powers the Linux kernel, but also because it has inspired and motivated the birth of many other languages that came after, such as C++, Java, and Python. In fact, many popular Python packages such as NumPy or TensorFlow are implemented through C or C++ under the hood. I thought learning C would be a fruitful endeavor during these sad quarantine hours, so here is my reflection, plus a short crash course for those who want to get a taste of what C is like.

Introduction to C

Let me put this out there first: C is not an easy language to pick up. This might be in large part due to the fact that I come from a Python background: Python is a dynamically-typed language, meaning that programmers don’t have to worry much about things like data types. In fact, it’s possible to do something like

some_var = 5
# some lines of code
some_var = [1, 2, 3]
# more lines of code
some_var = 'this is a string'

And Python will compile these without error.

C, on the other hand, is very picky about data types and will print out a myriad of error statements to complain that something has gone astray.

int someVar = 5;
// some lines of code
someVar = {1, 2, 3}; // error

To be fair, however, this is not a problem exclusive to C: as far as I know, other languages like Java or even Swift will complain that such reassignments are impossible. It’s fairer to say that Python is the oddball here.

But then there’re pointers. Pointers are one of those things that might seem doable and somewhat intuitive at first, then throws you off when you actually start coding, then gives you a false sense of security that you’ve grasped the concept, only to surprise you from behind with another set of complications. Even simple I/O functions like printf seem daunting at first. I’ll shamelessly admit that it took me a full day or so to wrap my head around what was going on in these lines of code the first day I decided to venture into the world of C.

// very basic pointers
int n = 5;
int * ptr = &n;
printf("%p\n", &n); // 0x7ffee67bd96c
printf("%p\n", ptr); // 0x7ffee67bd96c
printf("%d\n", *ptr); // 5
printf("%d\n", n); // 5

Oh, and did I mention the fact that arrays and strings are a whole different animal in the world of C? I’ll stop here and go into more detail in later portions of this post, but in short, the conclusion is simple: pointers are confusing. And thus also is C.

Why C

Then why learn C in the first place? After all, C is a pretty old language, and although it is used in the development of operating systems, kernels, and embedded systems, its applicability is more limited compared to something like Python or even C++.

I personally think learning C helps us better understand how computers work and what’s actually going on under the hood. Because C is a rather low-level language, it is more machine-friendly and thus reveals more of what happens when lines of code are executed, a lot more than, say, Python. Granted, one can see memory addresses of where variables are stored as well, using something like

>>> x = 1
>>> hex(id(x))
'0x1083cd480'
>>> del x

But this is if we really try.

C is designed in a less beginner-friendly fashion, so that it requires the programmer to think about things like garbage collection and memory management on the stack, heap, and so on. It also forces you to think more about overflow, which occurs when information assigned to a variable exceeds the maximum amount of information that can be stored in a given datatype’s representation. These are all basic computer science concepts, but because I was never trained to think rigorously or systematically about these issues, learning C was a challenging, refreshing experience.

Enough of my story, let’s take a look at some of C’s basic syntax structures.

C Crash Course

This is not really going to be a full-fledged course, so perhaps crash course is a misnomer. But you get the idea: we’ll review some basic syntax to get a sense of how things work in C. Note that this may not be the most beginner friendly introduction to C programming: if you’re looking for a resource for starters, I highly recommend Introduction to Programming in C offered by Duke University on Coursera.

One last note for my fellow Java programmers before we dive in: Java is C’s step cousin. If you know Java’s syntax, a lot of C’s syntax will seem familiar to you. But do not be fooled by the exterior familiarity: C’s behavior is often times different from that of Java’s, especially when it comes to things like pointers.

Declaration and Initialization

This looks identical to Java for the most part.

int a; // declaration
a = 5; // intialization

Functions are also pretty straightforward.

int add(int x, int y){
  return x + y;
}

One point of caution: variables that are declared, initialized, and modified within the function are killed once the function’s sequence is terminated, and thus its stack frame removed from computer’s memory. In other words,

void modifyVar(int var){
  var = 10; 
}

int main(void){
  int test = 0;
  modifyVar(test);
  printf("%d\n", test); // still 0
  return EXIT_SUCCESS
}

Although this may appear confusing at first, this makes a lot of sense if you start thinking in terms of stack frames. From the point of view of the function, in this case modifyVar, the function has no knowledge of the actual variable that it is passed: it only knows the value of that parameter. Therefore, to way to go about this problem is to use pointers instead.

void fixedModifyVar(int * var){
  *var = 10;
}

int main(void){
  int test = 0;
  modifyVar(&test);
  printf("%d\n", test); // now 10
  return EXIT_SUCCESS
}

If the whole & and * business seem confusing to you, don’t worry: the next section is dedicated to pointers and memory addresses.

Pointers and Memory Address

In C, everything is pass-by-value. This is why we could not modify a value within a function: the argument that is given is simply the value of that variable, not the reference to the variable itself. Therefore, if we want to modify objects outside of a function in C, we need to pass in what are called pointers. This stack overflow post explains the notion of emulating pass-by-reference in C by passing pointer values as we have done in the short example above.

Put quite simply, a pointer is an object that—as the name implies—literally points to another object in the computer’s memory. The way this works is that the pointer stores the memory address of the object that it is pointing to. For example, recall this previous example

// very basic pointers
int n = 5;
int * ptr = &n;
printf("%p\n", &n); // 0x7ffee67bd96c
printf("%p\n", ptr); // 0x7ffee67bd96c
printf("%d\n", *ptr); // 5
printf("%d\n", n); // 5

As you can see, the memory address is a hexadecimal number that simply tells us where a certain data is stored. The number should be a 64 bits, or 8 bytes, but because I’m not using a proper data type such as uint64_t, the length of the memory addresses are truncated.

Let’s look more into the code. In this code snippet, ptr is a pointer that points to the integer n. The int * written in the declaration of ptr indicates that ptr is a pointer to an integer. And since ptr is a pointer, we initialize it with the memory address of n, which can be obtained via the ampersand symbol, &.

Arrays

One of the most confusing things about C is making sense of the distinction between a pointer and an array. Unlike in some other languages, where arrays are given first class citizen support, the notion of arrays is slightly more muddled in C. I’ve sifted through countless SO posts to understand whether the following statement is true:

In C, arrays are equivalent to pointers.

Indeed, this proposition seems very true if you try something like this:

int arr[] = {1, 2, 3};
printf("%p\n", arr); // 0x7ffeea0fc95c
printf("%p\n", &arr[0]); // 0x7ffeea0fc95c
printf("%p\n", &arr); // 0x7ffeea0fc95c

This result seems to suggest that there is no difference between an array, a pointer to an array, or the pointer to the first element of the array. Indeed, it appears as if an array is just really a pointer. In fact, it is even possible to store an array into a pointer (or at least appear so):

int arr[] = {1, 2, 3};
int * ptr = arr;

However, a pointer is not an array, and the two should not be confused. As one of the gurus on SO memorably said,

Pointers are pointers and arrays are arrays.

Indeed, this statement will understandably trigger the GCC Compiler to complain that something is wrong:

int * ptr = {1, 2, 3};

Specifically, it states that warning: incompatible integer to pointer conversion initializing 'int *' with an expression of type 'int' [-Wint-conversion]. This message simply means that a pointer is not an array, which is why it is impossible for a pointer to store an array.

Then, what is the relationship between a pointer and an array?

The answer is that an array often decays into a pointer to the first element of that array. This decay happens, for instance, when an array is passed into a function as a parameter. Let’s consider the example of a function that prints a 2D array.

#define WIDTH 3
#define HEIGHT 3

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

int main(void){
    
    int data[HEIGHT][WIDTH] = {{0, 1, 2}, {3, 4, 5}, {6, 7, 8}};
    
    printArray(data);
    return EXIT_SUCCESS;
}

Note that the function printArray is passed in a 2D array, data. However, the function printArray accepts a pointer to an array of size WIDTH. Why is this the case? Well, we simply recall the fact that an array decays to a pointer to the first element of that array. In this case, because data is a 2D array, its first element is a 1D array of length WIDTH. Therefore, printArray accepts a pointer to an array as its argument.

Something that I was very confused when first starting out with pointers and C in general was the conflation of double pointers with 2D arrays. The two should not be confused. For example, in the printArray function above, if we define the parameter as something like int ** arr, we would be greeted with a compiler error. 1D arrays decay into int * pointers, but this does not mean that 2D arrays decay into double pointers; instead, as we have seen, they decay into int (*)[], or pointer to an array.

Decay and Size

One important fact that deserves its own subsection, related to the topic of pointers and arrays, is the sizeof operator. Earlier we have noted that arrays frequently decay into the pointer to the first element of that array. However, there are exceptions when this is not the case: sometimes, the array retains its form and produces results differently than we would expect if we were to consider it a pointer to the first element.

For example, consider the following code snippet:

int arr[10];
size_t arrSize = sizeof(arr); // 40

On my work station running on macOS, int is 4 bytes, which is why the size of arr is 40. This is clearly not the result we would get if we calculated the size of the pointer to the first array.

size_t ptrSize = sizeof(&arr[0]); // 8

In other words, the general rule of thumb that arrays decay into pointers to their first elements holds true for the most part, with several exceptions, the most notable and common of which is the sizeof operator.

Strings

Another point of confusion I ran into was the difference between

char strA[] = "abc";
char * strB = "abc";

These two seem to achieve the same end goal: creating a string variable that contains "abc". However, under the hood, strA and strB are very different. In other words, char[] and char * are two distinct ways of initializing strings: the first method creates a char array that stores a string-literal, as the syntax suggests; the second method, on the other hand, creates a pointer to a string-literal, which is stored in the read-only portion of the computer’s memory.

A corollary of this difference is that assigning or modifying contents of the string is only legal in the case of the first array-type declaration. Concretely,

strA[0] = "d"; // legal
strB[0] = "d"; // illegal

The array-type initialization method should more accurately be understood as a short-hand for

char strA[] = {"a", "b", "c", "\0"};

The last element is the null terminator, which is basically a way for computers of knowing that the string has ended and to ensure that the contents of the array is a valid C string. Note that null terminators are necessary only to make some data a valid C string; hence, it is not a requirement of the char[] datatype to have a null terminator as its last element.

Another confusion that might throw of some beginners is the use of const in declaring and initializing strings.

const char * strC;
char * const strD;

The difference, to put it in human language terms, is that strC is a pointer to a constant character; strD, on the other hand, is a constant pointer to a character. The implications of this are best demonstrated via an example:

char a = 'A'; // single quotes for char
char b = 'B';

const char * strC = &a;
*strC = b; // illegal
strC = &b; // legal

strC is a pointer to a constant, which means that the pointer itself can be modified, whereas the object itself is pointing to cannot. Therefore, one cannot deference that pointer to modify its content, which is what *strC = b is attempting to do in the example above.

For strD, which is a constant pointer to a character, the situation is slightly different.

char * const strD = &b;
*strD = a; // legal
strD = &a; // illegal

Because strD itself is a const variable, it cannot be assigned a new memory address to point to.

We can combine both worlds to create something like

const char * const strE;

In this case, strE cannot be dereferenced to alter the value it is pointing to, nor can we modify the pointer itself to refer to a new memory location. This is because strE is a constant pointer to a constant character. By now, you should be a wizard at reading character pointers.

Ternary Operators

Let’s take a break from pointers, arrays, and strings and talk about something simpler. In C, there is a shorthand for if-else conditional statements, known as ternary operators.

int a = 1;
int b = 2;
int larger = (a > b) ? a : b;

This is just another way of saying

int larger;
if(a > b){
  larger = a;
} else{
  larger = b;
}

The ternary operator is a lot more concise than the conditional statement. Of course, once we get into more complicated code, there will most definitely be cases when we want to use if-else instead of the ternary operator, but it is a good tool to have nonetheless. Personally, I’ve found it useful when declaring some simple macro functions.

#define MAX(x, y) (((x) > (y)) ? (x) : (y))

Then, we can now use MAX and pretend like it is a built-in function.

int larger = MAX(a, b);

Dynamic Memory

Previously, we mentioned the fact that variables that are declared and initialized in a function expires once the stack frame pertaining to that function is terminated after the end of the sequence. However, turns out that there is a way to keep these variables remaining, even after the function is fully executed and the stack frame removed.

The key behind this magic is malloc, which stands for memory allocation. What malloc does is that it allocates a memory for some object on the heap, not the typical stack. This way, the object will keep on living, even outside the function scope after the execution is over. To remove that object, we need to manually free that variable. This is how memory management and garbage collection works in C: through the manual control of the programmer.

int length;
scanf("%d", &length);
int * arr = malloc(sizeof(int) * length);
// some lines of code
free(arr);

This will allocate space for an integer array of length length. Why would we want to do this in the first place? Note that, when we declare and initialize arrays, we need to specify its length. If we do not know the size of the array before compile time, however, this becomes a problem. One way to solve this issue is use malloc precisely as shown above to dynamically allocate space for the array according to, say, the value inputted by the user via scanf. Note that we must free the malloced variable so that the heap is flushed.

malloc also allows us to declare variables and let them live beyond the scope of the function. Consider the following dummy function:

int * dummy(void){
  int x = 1;
  return &x;
}

The reason why this doesn’t work is that x no longer exists by the time the function is executed. Instead, a workaround is to allocate x on the heap using malloc.

int * dummy(void){
  int * xPtr = malloc(sizeof(int));
  *xPtr = 1;
  return xPtr;
}

This way, we will be able to return xPtr, since it is dynamically allocated on the heap. Of course, we will have to free it at one point for garbage collection once everything is over.

realloc is a way of re-mallocing a pointer. This might happen in cases where we want to extend the size of a dynamically allocated array-like pointer (which I will simply refer to as array for convenience purposes in this subsection). Say we have an array of size prevLen, but we want to expand it to newLen. In this case, we might do something like this:

int * arr = malloc(sizeof(int) * prevLen);
// some lines of code
arr = realloc(arr, sizeof(int) * newLen);

This will expand the size of arr to newLen while copying its original contents. Note that realloc may very well change the memory address of arr to a completely different location on the heap.

Conclusion

This post in no way covers even an atom of perks and beauties of the C programming language. Instead, I wrote this post in the hopes of helping those who might be thrown off by the complexities and subtleties of C, as well as for myself, the helpless C novice who always revisit the same SO threads each time he runs into the same question he literally searched for the day prior. Learning C has been a challenging yet also a very rewarding experience so far, and I plan on continuing it so that I can build a solid foundation. I’ve also found that LeetCoding or solving easy coding questions with C is an interesting experience, as it requires more thinking that solving them with Python.

I will be referencing C for some posts on data structures I plan on writing, so hopefully this will build a solid foundation for what’s more to come. In the meantime, take care, and see you in the next one.