Special thanks to the incomparable Sylwia Vargas for helping me structure and focus this post better, as well as to Uli for making kind corrections.

Late last week, I posted on social media about going back to an old macOS Objective-C codebase I’ve worked on for over a decade, prompting the question “what is Objective-C used for”?

After nerding out in a thread about it, I realised there was so much to Objective-C, the time it’s from, and why I’m so grateful I learned it early in my career. That’s where this post comes in.

But to be clear, the purpose of this post is not to advocate for learning Objective-C, but to appreciate the lessons I learned from it towards becoming a more versatile engineer. I don’t write much Objective-C anymore, but it has a special place in my heart.

Some context on me and Objective-C

My first paid work as a software engineering freelancer was in fixing bugs for existing macOS apps. This was back in 2009: the operating system was called Mac OS X (I’ll be calling it macOS in this post to keep things consistent), its apps were written in Objective-C, and Xcode, Apple’s software development, was in version 3. Today? We have macOS with apps written in Swift, and Xcode is in version 14.

It’s a language that, as the name might imply, is an object-oriented extension of C. It has changed significantly over the years as the Apple development environment and language standards have evolved.

While I would recommend choosing Swift for modern native Apple development, looking at my time with Objective-C in hindsight helped me appreciate the Computer Science fundamentals I learned from it that I find cropping up later in my career. This post covers some of them.

Different syntax offers different perspectives

Remember when you first learned to code, and how things didn’t fully click at first? For example, to many new developers concepts like dot notation (for example: user.email or button.setLabel("Reset")) take a moment to settle in. Oftentimes, it doesn’t feel intuitive that dot notation is used for calling functions or retrieving data. This is something I’ve run into often when introducing folks to programming concepts.

Turns out that while Objective-C uses dot notation for C-like getters and setters, most complex method calls will require square-bracket notation.

Let’s illustrate this with the following example. When coding a macOS app in Swift, if we wanted to get the title of the app’s main window, we’d call the following:

NSWindow.mainWindow.title

In Objective-C, each message sent (that is, function called or data retrieved) has to be done with square brackets:

[[NSWindow mainWindow] title];

Let’s look at a breakdown of each pair of square brackets:

  1. [NSWindow mainWindow] sends the message mainWindow to the NSWindow class, which returns the application’s main window as an object.
  2. [[NSWindow mainWindow] title] sends the message title to the main window object, which returns the window’s title in the form of a string.

What about when we want to call a method that takes a parameter? Let’s look at the following example that sets the label of a button in a window:

[[preferencesWindow resetButton] setLabel: @"Reset"];

So here’s a fun fact: Objective-C is based on a programming language called Smalltalk, which is particularly significant for laying the foundations for how we write object-oriented code today. Languages like Python, Ruby, Dart, Go, Java, Scala, and more have all been influenced by Smalltalk! Which yes, includes Objective-C.

While not common, square bracket syntax has a few advantages, such as being able to have a clearer separation of object and message when the dot notation is replaced with a whitespace. Furthermore, dot notation in Objective-C adds a layer of ambiguity in not being 100% clear whether a method is being called or a property is being accessed, whereas bracket notation always makes it clear that we’re sending a message.

Having this versatility for changing languages and embracing new syntaxes has helped me grasp concepts like Elixir’s pipe operator smoothly!

Pointers on Objective-C Pointers

In Objective-C, a variable is a named memory location that can hold a value of a particular type, just like in other programming languages. A pointer, on the other hand, is a variable that stores the memory address of another variable.

Hold on, a memory what?

A memory address can be thought of as a bookshelf location in a library. Just like a book has a specific location on a shelf, a piece of data in a computer’s memory has a specific address, or location. The memory address serves as a unique identifier that can be used to locate and retrieve the data stored in memory. They’re usually represented in the hexadecimal format, such as 0x7fff5fbff8f8. Just as a librarian needs to know the bookshelf location of a book in order to retrieve it, a program needs to know the memory address of data in order to access it.

Let’s have a look at how pointers are declared and used in Objective-C:

// Declare a variable 'myBook' and assign it a value of 42
int myBook = 42;

// Declare a pointer 'myBookmark' and assign it the memory address of 'myBook'
int *myBookmark = &myBook;

// Print the value of 'myBook'
NSLog(@"The value of myBook is %d", myBook);

// Print the memory address of 'myBook'
NSLog(@"The memory address of myBook is %p", &myBook);

// Print the value stored at the memory address pointed to by 'myBookmark'
NSLog(@"The value stored at the memory address pointed to by myBookmark is %d", *myBookmark);

In Objective-C, NSLog prints to the console, and is the equivalent of puts in Ruby.

The output would look like this:

The value of myBook is 42
The memory address of myBook is 0x7ffee2d01a6c
The value stored at the memory address pointed to by myBookmark is 42

Why Use Pointers?

We use pointers in programming languages like Objective-C for several reasons:

  • Efficient memory usage: Pointers allow us to pass data by reference, rather than by value. This means we can avoid copying large amounts of data when passing it between functions, leading to more efficient memory usage.
  • Data structures: Many data structures in Objective-C, such as linked lists and trees, rely on pointers to store and manipulate data.
  • Dynamic memory allocation: More on that shortly 👀

And guess what, although a lot of higher-level languages don’t use them, modern ones certainly do, like Rust or Golang. I’ve found this to be helpful when understanding how WebAssembly’s Linear Memory works, for example.

Mastering Manual Memory Management

Nowadays, most programming languages have a built-in automatic garbage collection mechanism. Garbage collection is a way for a computer program to automatically clean up the memory that is no longer being used by the program. It helps to free up space in the computer’s memory so that it can be used for other things.

Objective-C did later on add their own mechanism for garbage collection, but not when I started.

This means that as a programmer, I am responsible for allocating memory for data structures or objects, and then releasing that memory when it is no longer needed. In programming languages like C, you would need to specify the number of bytes you needed to create a variable containing a string. In Objective-C, this was done dynamically:

NSString *myString = [[NSString alloc] init];

This code declares a pointer variable named “myString” that points to an instance of the NSString class in Objective-C.

The statement [[NSString alloc] init] creates a new instance of the NSString class using dynamic memory allocation, and initializes it with a default value. The pointer to this new object is returned by the alloc and init methods.

With that said and done, we can go ahead and use this in a method:

- (void)myMethod {
    NSString *myString = [[NSString alloc] init];
    // Do something with myString
}

Here’s the thing: The memory has been allocated, but it will then need to be released.

Why do we need to release memory?

Computers have a limited amount of memory. If you do not release the memory allocated for the object, it will not be freed and won’t be able to hold any other data. Sure, this isn’t a huge deal for, say, one string, but what if we’re failing to release dozens, hundreds or thousands of strings?

This can result in a memory leak, which can lead to a shortage of memory, which in turn can cause the program to slow down or crash. In extreme cases, the entire system may become unstable, and other programs may also be affected.

We can release the memory manually, with the release message, available to all objects in Objective-C.

- (void)myMethod {
    NSString *myString = [[NSString alloc] init];
    // Do something with myString
    [myString release];
}

This indicates that we’re done with myString and can release the memory allocated to it.

Cool, but I work with a garbage collected programming language. Is there a reason this is good to know?

I’ll do you one better — here’s four reasons:

  1. Debugging: While garbage collection automatically manages memory for you, there may still be cases where the program is not functioning as expected due to memory problems. Understanding manual memory management can help you diagnose and fix these issues.
  2. Performance optimization: Garbage collection can be resource-intensive, and there may be situations where manual memory management can provide a performance boost. Understanding manual memory management can help you identify these situations and write code that is more efficient.
  3. Portability: Not all programming languages use garbage collection, and not all environments support ones that do. For example, when trying writing code for microcontrollers, I had to use a low-level C language and had to carefully, manually manage memory.
  4. Code optimization: Even in languages with Garbage Collection, understanding manual memory management can help you write code that runs quicker. For example, you can use techniques like object pooling to reuse memory instead of allocating and deallocating memory frequently. Fun fact: this is how long lists can be rendered in mobile apps.

The advantage of manual memory management is that it provides a high degree of control over how memory is used in a program. However, it also requires careful attention to detail.

But, you may be wondering, what happens when you want to use that allocated memory elsewhere, say in…

Low-level concurrent programming

In short, learning Objective-C helped me understand concurrent programming, which is the practice of allowing multiple tasks or processes to execute simultaneously.

This is something we do very often in our daily coding lives. For example, in JavaScript, we use promises and async functions for HTTP requests or to update a database.

When coding macOS apps, we can call upon a background thread to perform asynchronous tasks. Let’s say we have an object for an ApiWrapper with a method called postData(jsonData) that makes a POST request. We can call upon it in a background thread:

[apiWrapper performSelectorInBackground:@selector(postData:) withObject:jsonData];

How about if we wanted to update the UI of our app in that postData method?

Here’s the thing: macOS requires you to only update the UI on the main thread. So what do we do in postData()? Fortunately, we have a solution for this:

- (void)postData:(NSData *)jsonData {
    // Perform HTTP request

    [self performSelectorOnMainThread:@selector(updateUI:) withObject:[response parsedJsonData] waitUntilDone:NO];
}

This way, we get direct access to the main thread.

It’s all fun and games until a race condition arises

When coding multi-threaded applications, we need to make sure that accessing shared resources is done so safely. Let’s check out an example:

- (void)updateSharedValue {
    // This method runs on a background thread
    self.sharedValue += 1;
}

- (void)startBackgroundTask {
    [self performSelectorInBackground:@selector(updateSharedValue) withObject:nil];
}

// Start two background tasks that update the shared value
[self startBackgroundTask];
[self startBackgroundTask];

// Wait for the tasks to complete
[NSThread sleepForTimeInterval:1.0];

// At this point, the shared value should be 2, right?
NSLog(@"Shared value: %ld", self.sharedValue); // Output: Shared value: 1

What happened? Both background tasks are updating the same shared variable concurrently. Depending on the timing and scheduling of the threads, one task might overwrite the value updated by the other task, leading to incorrect or inconsistent results. This is known as a race condition. A race condition is a type of software bug where two or more threads or processes access a shared resource concurrently. In such a case, the final result depends on the order of execution, which can be unpredictable.

It’s like a game of musical chairs where the number of chairs is less than the number of players. When the music stops, everyone rushes to grab a chair, but some players are left standing without a seat. The outcome is unpredictable and depends on the timing of the players’ actions. While this is fun for humans, you wouldn’t want your serious app to behave this way.

Mutexes to the rescue

Luckily, we have the ability to use a mutex to control shared resources in Objective-C. A mutex is a tool for ensuring that only one thread can access a shared resource (like a variable or a piece of memory) at a time. It works by allowing a thread to “lock” the resource while it’s using it, which prevents other threads from accessing it until the first thread “unlocks” it.

In Objective-C, this is done by invoking the @synchronized directive. Let’s see it in action by using it in the previous multithreaded example:

- (void)updateSharedValue {
    // This code can only run if nothing else is synchronized on the sharedValue property
    @synchronized(self.sharedValue) {
        self.sharedValue += 1;
    }
}

With the above change, only one thread can execute this block of code at a time if they are synchronized on the same object. This ensures that modifications to the shared value are made in a thread-safe way. No more musical chairs, all threads know how to queue to access the value.

Is this relevant in modern-day language use?

Absolutely! Modern languages have found ways to ensure developers comply with thread safety. For example, the Rust programming language has the ownership model built-in as a feature with concurrent coding and memory safety in mind.

Overall, understanding thread safety helped me write more robust and performant code, avoid bugs and crashes, and create a better user experience for the users of the applications I developed.

Learning from history

Having been around the programming language for over 10 years, it’s been fascinating to see Objective-C evolve as a language, taking influence by new competencies and priorities in the tech industry.

Automatic Reference Counting (ARC)

Remember all that stuff I wrote about manual memory management? Apple seems to have agreed that this wasn’t the most optimal way to write code, and created ARC, which automates this process by inserting retain and release calls at compile time.

Grand Central Dispatch (GCD)

To improve multithreaded code, Apple created the Grand Central Dispatch API , making several improvements on the safety, code readability and level of control for multithreaded code.

Blocks

Blocks are a language feature that allow developers to create anonymous functions or closures in Objective-C. They are similar to lambda expressions in other languages and are often used for asynchronous programming and concurrent programming.

That’s right! They didn’t exist in the language at first. Below is an example of one:

void (^myBlock)(int) = ^(int num) {
    NSLog(@"The number is %d", num);
};
myBlock(42);

The syntax for defining a block looks a bit unusual because it uses the caret symbol (^) and parentheses to specify its parameters. This is designed to be similar to C’s function pointer syntax.

Literals

Creating objects like arrays or dictionaries used to be quite unwieldly. Not anymore!

// Old way of creating an NSArray:
NSArray *myArray = [NSArray arrayWithObjects:@"apple", @"banana", @"cherry", nil];

// New way of creating an NSArray:
NSArray *myArray = @[@"apple", @"banana", @"cherry"];

Wrapping up: How Objective-C makes me feel today

Looking back at it, I’m so grateful for having spent a good chunk of my early years with Objective-C 😌. I often say that it taught me computer science, in that it helped me grasp several concepts that go into my daily work.

More than anything, it taught me to be versatile.

Learning the lower-level concepts helped me understand better how computers work, giving me a better frame of reference when designing optimal architectures.

Knowing how the underlying system works can help you better understand the root cause of bugs or issues and work one of our most important technical muscles: Problem solving.

Lastly, having a strong foundation in computer science can make it easier for you to learn and adapt to new technologies and programming languages. Versatility is at the heart of this, and I treasure this skill immensely to this day.

Buy me a coffee