Post

A Roadmap to Self-Study Programming

Step 1: Getting Started

There are many conflicting arguments on the best way to learn how to program; as best I can tell, there are at least three major schools of thought about which language to start with:

  1. C, which is “close to the metal” and allows for building up from the “actual” foundations;
  2. Lisp (or something close to it), which forcefully ingrains good programming habits and teaches you to think in a way that most regular paradigms won’t; and
  3. Python, C#, or Java, because they’re commonly used in industry, relatively easy to pick up, and will allow you to quickly build something that you’ll actually use.

Having suffered through all three of these introductions, I generally prefer option 1. The part about C being “close to the metal” is kind of a lie, but it will force you to engage with features that other languages paper over: specifically, working with things like pointers.

I highly recommend you start with Harvard’s CS50, their introductory course for computer science. The lectures are fun and give a wide view of computer science as a subject (touching on things like cryptography, complexity, data structures, computer architecture, and so on) before moving onto Python, a substantially more modern and user-friendly language.

Seriously, that’s my answer. Go do CS50 and return after you’ve finished it.

Step 2: Functional Program and Basic Program Design

After you’ve gotten the basics down, I actually recommend you take a look at Northeastern University’s Fundamentals of Computer Science 1. I think this course is generally inferior to CS50 as an introduction to computer science, but it’s designed to be more like an introduction to software development. It’s taught using DrRacket, a teaching language based off Lisp, and stresses a couple things that CS50 doesn’t cover like:

  1. Functional programming. Rather than treating your program as a step-by-step list of instructions, you think about programs like a description of what you want your program to do. This is a pretty fundamental shift, and you’ll think about ways to do things like iteration without loops and manipulating data without mutation. This is hard and not very intuitive, but is an extremely powerful way to program when it clicks. You should get familiar with concepts like map, filter, and fold.
  2. Program structure. A major theme of the course that ties together both functional programming and software testing (which we’ll discuss below) is the need to break programs down into functions that:
    • Accomplish only one task
    • Have clear documentation explaining what it does
    • Can possibly be used in multiple places
    • Break code down into easily understandable components, reducing cognitive load

    Deciding how to slice up a program into constituent functions is really hard, and learning to do it right is an important skill.

  3. Software testing. The course functions as an introduction to test-driven development, where you write tests for each of your functions before you actually write the functions themselves. This helps you design your tests (if you’re struggling to write tests, your function might be doing too much!) and also helps you to think about edge cases you might encounter as you’re writing your function. The purpose of designing our program structure well is to reduces the problem of testing an entire program down to just testing individual functions.

Let me provide an example that might make sense. Frequently in robotics, data science, machine learning, etc. we’ll have two pieces of data called vectors that are essentially just a list of numbers, and we would like to perform an operation called the dot product on these two vectors. The dot product works like this: given two vectors $v = [v_1, v_2, \ldots, v_n]$ and $u = [u_1, u_2, \ldots, u_n]$, the dot product is the sum $v \cdot u = v_1 \times u_1 + v_2 \times u_2 + \cdots + v_n \times u_n$. We can’t take the dot product of two vectors if they don’t have the same number of elements.

Compare the following two implementations of the dot product in Python:

1
2
3
4
5
6
7
def dot(v, u):
    if len(v) != len(u):
        raise ValueError("Vectors have different length!")
    result = 0
    for i in range(len(v)):
        result += v[i] * u[i]
    return result
1
2
def dot(v, u):
    return sum(a * b for a, b in zip(v,u))

These two implementations do the same thing, but you might already have the sense that the second implementation feels much better: it’s much shorter, much easier to understand, and it’s obviously correct if you know what sum and zip do. Contrast this to the first implementation, we need to make sure we’ve written our conditional correctly, that we’re raising the correct error, and that the indexing we’re doing in our for loop is correct. All of these things are neatly handled in the second implementation by the list comprehension and zip function. By combining a handful of simple, reliable, general-purpose building blocks, we’ve made much better code.

Here’s another big idea: it doesn’t matter if you write something like the first implementation on your first try. Putting the specific implementation details in a function, (dot, in this case) allows us to refactor our implementation without breaking or changing any other code. We can think of functions as constituting a “contract” with anyone who calls our code: they provide the specified inputs, and the function provides the specified output. How a function does this is (more or less) its business: it’s a black box, so if we change what happens inside the black box, no one will notice. This brushes up against two huge and closely related ideas in software development called encapsulation and coupling.

Writing programs in this way provides several robust benefits:

  1. Our programs will be much easier to understand, change, and maintain
  2. We don’t waste time writing the same code over and over again
  3. We can very easily break down complex tasks into simple, understandable code by combining small, understandable, and reliable functions
  4. The problem of understanding what our programs do is reduced to the problem of understanding each of the functions
  5. Our code is much easier to test, provides evidence that it’s reliable, and helps us pinpoint the location of bugs

From there, you can download Python and read Automate the Boring Stuff with Python then my survey of intermediate Python features. It’s also crucially important to have a solid understanding of discrete math, which I’ve discussed in my how to learn math page.

For fun project ideas, see here, and try to build something that strikes your fancy!

Step 3: Git, the Shell, Debugging, and More

I highly recommend The Missing Semester of Your CS Education to learn about essential tools that computer scientists are expected to learn about through osmosis. Git is a major one, and sites you may have heard about like GitHub and GitLab exist to host Git repositories. Besides the basic Linux terminal commands like cd and ls, I recommend you useful commands like man, find, and grep. Spending a couple minutes to learn the options for each of these commands can save hours of work.

It’s also worth noting that anyone who uses GitHub will accidentally put something sensitive on there (passwords, API keys, etc) on there eventually, so I highly recommend saving this article on removing sensitive data from Git repositories for later!

Step 4: Object-Oriented Programming

Since the turn of the millenia, the most popular programming paradigm has been object-oriented programming, which were massively popularized by C and C++. Object-oriented programming presents a hierarchical way to encapsulate information, which essentially extends our ideas about functions to larger parts of our program: data, related functionality, and ownership.

A natural continuation is Northeastern’s CS 2510. These classes introduce Java, which popularized the object-oriented approach to programming. (As always when learning a new language, I recommend taking a peek at Google’s style guidelines: here’s their guide for Java). CS 2510 shows how to write code in Java using the functional techniques introduced in CS 2500; this provides a look at how powerful, robust techniques from functional programming can be translated to Java to solve problems in ways that wouldn’t occur to students who have only written imperatively. However, it’s clear by the end of CS 2510 that many of these techniques are a bit contrived and elaborate (the visitor pattern is frankly quite ridiculous and not worth using), so the latter half of the course introduces basic tools of imperative programming like mutation and loops, allowing students begin writing Java “normally.”

The next course in the sequence, CS 3500, serves as a larger scale course on software design and refactoring, and focuses on writing code with the Model-View-Controller design pattern and then refactoring and expanding it over the course of several assignments. The course draws heavily on Effective Java by Joshua Bloch and Design Patterns: Elements of Reusable Object-Oriented Software (commonly referred to as “gang of four”), both of which are crucial resources. I’d also include Game Programming Patterns as a reference for useful programming patterns, most of which arise in all kinds of software.

Part 5: Memory Management, and Even More Languages

By this point, you should have a basic grasp of C and feel comfortable solving problems in Python and Java. You may have noticed that these last two languages are thankfully free of obnoxious pointer management, malloc, and free. That’s good, right?

Well, that memory management still has to happen somehow. C++, which is often considered the “modern” successor to C, maintains the do-it-yourself approach to memory management: all memory is handled by the programmer. If they mess up and that causes a segfault, that’s just a skill issue. Unfortunately, expecting programmers to be smart enough to avoid memory errors is extremely difficult; in memory-unsafe (read: C/C++) codebases, 70% of bugs are memory safety related:

A recent study found that 60-70% of vulnerabilities in iOS and macOS are memory safety vulnerabilities. Microsoft estimates that 70% of all vulnerabilities in their products over the last decade have been memory safety issues. Google estimated that 90% of Android vulnerabilities are memory safety issues. An analysis of 0-days that were discovered being exploited in the wild found that more than 80% of the exploited vulnerabilities were memory safety issues [1].

These are in some sense the worst kind of bug, because they often allow for arbitrary code execution bugs, buffer overflow attacks, or just crashes.

So, how do we solve these bugs?

Garbage Collectors and C#

You’ve already experienced the first solution: programs written in languages like Java and Python implicitly include additional code, called a garbage collector, which owns all allocated memory and tracks every time anywhere else in the program refers to that code. Essentially, it tracks all the mallocs and handles all the frees. The garbage collector runs at regular intervals, and will free any memory that nothing references anymore. This prevents a huge class of bugs, but places a hard ceiling on the performance that the program is capable of, because unused memory might be retained for a long time if the garbage collector doesn’t run frequently enough. There ways for the programmer to influence the running of the garbage collector are often very limited, so optimization might be difficult; even if the programmer knows there’s a good time for the garbage collector to run, Java’s System.gc() and .NET’s GC.Collect(); treat these calls as strong suggestions. Even then, calling the garbage collector might make performance worse, because modern garbage collectors are designed on the assumption that they and they alone decide when garbage will be collected and optimize with that assumption.

In the late 90s and early 2000s, this was considered the definitive solution, especially as research into garbage collectors was a roaring success and expected to continue. Unfortunately, it appears that the gains we’ve made in garbage collection were mostly picking low-hanging fruit, and garbage collection has not gotten as efficient as its optimistic proponents would have expected. As such, there is still a meaningful performance hit when using garbage collectors.

If this solution is still satisfactory, then I would like to introduce you to C#, which I consider to be an “upgrade” over Java. It uses the same object-oriented model, but has but has loads of optional, additional features:

RAII, Ownership, and Rust

The second solution is to continue using a language like C++, but develop rules of thumb like resource acquisition is initialization, or RAII for short. This is a mental framework where the object, function, module, or whatever that allocates a piece of memory “owns” it, and is therefore responsible for initializing it (making sure it contains valid data), and ultimately freeing it, or giving that memory it to another owner who will assume responsibility for freeing that code.

This means that if I am programming in C++ and have an std::vector (a list), and I jam a piece of data into that list, I need to determine who has the rights to pull that memory out and mess with it, and make sure that I free it. Of course, as the owner, everyone else has to ask me to use that memory; this prevents use after free bugs. I am also the only person who frees the memory, so the memory is only freed once; freeing the same pointer multiple times can lead to memory corruption.

Of course, that 70% statistic from earlier isn’t from the 90s; those studies were all conducted relatively recently. RAII, as good as it works in theory, clearly hits some roadblocks before it is implemented in practice. In 2023, the United States government recommended that secure software move to memory safe languages; this would seem to direct us back to garbage collectors.

However, there is actually a way to salvage RAII and ownership by designing a language that enforces RAII and ownership. The only language to date that does this (as far as I know) is Rust. Coming from C and C++, we can imagine the borrow checker like an annoying compsci professor who requires that all your code obey RAII. Rust has taken inspiration from modern package managers, build systems, and functional programming to create convenient tooling and excellent guarantees for program safety, as well as convenient null and error handling. This is still, at its core, manual memory management; there is no performance cost from a garbage collector, and we have all the room for optimization and efficiency that C and C++ provide.

If this interests you, I highly, highly recommend that you consider learning Rust, perhaps even before seriously engaging with C++. Consider going back and rewriting any of your CS50 assignments in Rust. After you’ve gotten your footing, take a peek at The Rustonomicon.

Of course, you might also find it prudent to learn C++, in which case the best book I’ve found is A Tour of C++ by the language’s inventor, Bjarne Stroustrup. I’ll also mention Google’s style guidelines again. If you would like to continue learning C, simply take a C++ book, rip out roughly 70% of the pages, and read what’s left. Or you can just read something like Modern C. (I do not recommend learning from The C Programming Language book by Kernighan and Ritchie. It’s very old, and best practices have changed a lot from when it was written.)

(Go is another C/C++ alternative meant to fulfill a slightly different niche, but it suffers from a handful of weird design decisions that really hold it back. Like, a lot of weird decisions. It also isn’t really a systems programming language.)

Part 6: Algorithms

By this point, you’re getting close to done with general programming, and can think about moving onto specific domains like web development, embedded programming, and so on. The final necessity is algorithm design. I’ve neglected talking about performance until now, mostly because performance is much easier to fix retroactively than design. However, performance is still paramount in many settings. I don’t think there are a lot of great resources to self-study, so I’ll halfhearted recommend Algorithms because it’s the standard text, and recommend specifically the chapters on Big-Oh notation, divide-and-conquer, greedy programming, dynamic programming, graph algorithms, and linear programming. You can practice these techniques and do interview problems on LeetCode.

Further Reading

From there, you have the foundations to pursue a lot of other subjects. There’s a wealth of resources and books on graphics programming, game programming, network programming, systems programming, web design, and more. I also recommend checking out MIT OpenCourseWare.

This post is licensed under CC BY 4.0 by the author.