Why leaning C before Rust is not necessary

I'm going to support my claim that it is not necessary to start with learning C to appreciate Rust with examples.

Arguably the Raison d'être for Rust is to provide a language that can be used in situations where C might be used but avoiding the memory misuse mistakes that are easy to make in C. That is to say the borrow checker is the unique selling point.

About the simplest example of a memory use error for our student might look like this in C:

#include <stdio.h>

int* func() {
    int x = 42;
    return &x;  // Returns pointer to local variable
}

int main() {
    int* ptr = func();
    printf("Value: %d\n", *ptr);  // Dangerous! Accessing expired memory
}

Modern C compilers will warn about returning a pointer to a local variable. My clang does actually build it and produce the 42 when run. A typical insidious mistake in C. It will also produce the wrong result when optimisations are on.

The Rust equivalent of that example is this:

fn func() -> &'static i32 {
    let x = 42;
    &x  // Error! Cannot return reference to local variable
}

fn main() {
    let ptr = func();  // Would attempt to store expired reference
    println!("Value: {}", *ptr);  // Would cause undefined behavior
}

Which looks almost exactly the same. Except of course. But now it fails to compile with an error "returns a reference to data owned by the current function".

Given these examples are so similar, my question is why would start our student off with C?

Now a suggestion. If one clings to the "start with C" approach, why not show our student "Crust". The same example in Rust but using raw pointers:

fn func() -> *const i32 {
    let x = 42;
    &x
}

fn main() {
    let ptr = func();
    unsafe {
        println!("Value: {}", *ptr);  // Must use unsafe block to dereference raw pointer
    }
}

Looks almost exactly like the C version. Apart from that mysterious unsafe. But the reason for that becomes apparent almost immediately.

Interestingly Rust will build and run that with no error or warning at all, happily producing the wrong result. Perhaps one could argue that C has advantage in that resect. But that C warning is only a warning, it's not required by the language, not all compilers will give a warning, and in many situations there will be no warning even in
clang or GCC.

However try to run this with cargo miri run and we will get an error with a nice explanation of the problem. Sweet.

In order for our student to understand this one has to get into talking about local variables and the stack and hence lifetimes. No matter if we start with C or Rust. Might as well skip the C part.

Edit: Hmm... A radical suggestion... perhaps starting off Rust students with Crust and raw pointers instead of references is actually easier. It does away with those mysterious lifetime tick marks, I mean like the 'static', until our student is in a position to appreciate their purpose and meaning.

I agree in parts. I have been thinking of a low level introduction myself, and my conclusion was, that a start with some basic C would be one way, and to start directly with some low level (unsafe) Rust would be the other way. (Starting with exotic languages like Zig or Nim would be possible as well, but I think that introducing those have no real benefit.).

One advantage for starting with C is, that you can feel the damage and actually burn your fingers. These C runtime errors are always very impressive, like "core dump", "segmentation fault", and such. In Germany, we have the popular example, that a child only learns no to touch the hot cooking plates, when it has done it at least once.

I feel that you hate C, and try to avoid it for that reason. I hate C also a bit, I started programming with the Wirthian languages, and used some minimal C later. For me C was initially ugly, with a confusing syntax, harder to learn and more error prone than Modula. But later I regretted not using C earlier, e.g. because a lot C code exists -- I was for a long time not able to study existing C sources.

Conclusion: You might be right, Rust learners do not need C. But on the other hand, some basic C does not hurt. The same is true for Python. Rust learners do not need Python, but some Python does not hurt. And both, C and Python can be useful, even when one might prefer Rust.

2 Likes

I believe that learning to program involves a series of skills that you stack on top of each other.

The base would be the skill of structuring the flow of instructions and data to solve simple problems. You don't need C for that, and I'd actually recommend something else to focus on the essential first.

If you want to go further in the memory management direction (there are others, like functional programming), then a language like C or C++ should be a good intermediary. Or you can start right there, of course.

If you want to go even further, Rust is nice because it makes you learn to be rigorous and handle lifetimes and aliasing explicitly when you write code, or it won't compile. I think it's easier to grasp those notions if you already know that there's a stack, that memory can be allocated on the heap, that pointers must not be null, and that only one part of code should be responsible to deallocate the data once no other part uses it any more. You'll also appreciate the advantage Rust is offering if you know the problems behind (otherwise, you may just wonder if it's worth going on).

By contrast, GC languages gives you the luxury of mostly ignoring those notions.

So I think it makes it much easier, but it's not mandatory. Going from nothing to Python to Rust shouldn't be too hard (and I think I'd rather recommend that than nothing to C to Rust).

There's something else, though. Often, learning is motivated by the goal you want to reach. If you're passionate about OS and if that's what you want to do, then C/C++ seems like a very good way to start or to spend a significant amount of time before switching to Rust, simply because of all the existing literature, code, and projects. But the same probably goes with other languages for other domains. So I'd also consider that factor.

This might sound strange for this forum, but I would generally expect starting with a very high level language like Python, JavaScript or Lua to get the basic concepts down first would be most helpful!

The main thing you definitely want to avoid is throwing too many things at someone at once, and while in production these languages and up being at least just as complicated to use, they're slow to introduce that complexity.

C has been used also because of that lack of initial complexity; assuming you can hold back your instincts on the "right way" to do something, you can get quite far in C without pointers (other than strings) and can then start introducing the concept only after they have some intuition about moving values around.

Whether you could do the same in Rust is something I'm not sure about - I can definitely see some arguments to be made for Rust (eg cargo run is simpler than gcc foo.c -o foo; ./foo), but it's not completely a slam dunk (explaining why types are sometimes inferred, basic features being traits, Cargo.toml etc...)

3 Likes

No, not at all. I'm very much attached to C. I learned C, on the job, in 1984 and have used it a lot since. I love the fact that it is about the simplest possible high-level language that enables that one man can write a compiler for in a reasonable amount of time, that enables cross platform systems programming. Admittedly I was somewhat shocked to find how sloppy C is while working through all those segfaults and such in the early days. Because I had perviously programmed in BASIC and ALGOL where that was not a thing. But I had also done a fair amount of assembler programming before that so I so came to appreciate why C is the way it is and learned to live with it. At least accepted it as there was no alternative.

Yes indeed. I'm all for children burning themselves on hot plates. My point in this thread though is that all that core dump and segfault fun can just as easily be had with Rust. All one has to do is start with Crust: Introducing Crust. Like C/C++ but C/Rust

One might need a little bigger exercise to get into the segfault and core dump fun. For example the classic linked list exercise for C beginners looks like this:

use core::ptr;
use libc::{c_void, free, malloc, size_t};

#[repr(C)]
pub struct LinkedList<T> {
    head: *mut Node<T>,
    tail: *mut Node<T>,
    len: usize,
}

#[repr(C)]
struct Node<T> {
    next: *mut Node<T>,
    prev: *mut Node<T>,
    element: T,
}

pub struct Iter<T> {
    current: *mut Node<T>,
}

impl<T> Default for LinkedList<T> {
    fn default() -> Self {
        Self::new()
    }
}

impl<T> LinkedList<T> {
    /// Creates an empty `LinkedList`.
    ///
    pub const fn new() -> Self {
        LinkedList {
            head: ptr::null_mut(),
            tail: ptr::null_mut(),
            len: 0,
        }
    }

    /// Appends an element to the back of a list.
    ///
    /// This operation should compute in *O*(1) time.
    ///
    /// # Safety
    ///
    /// This function is unsafe because it:
    /// - Dereferences a raw pointer (`self`)
    /// - Performs raw memory allocation
    /// - Dereferences memory that was just allocated
    ///
    /// The caller must ensure that `self` points to a valid `LinkedList<T>`.
    pub unsafe fn push_back(self: *mut Self, elt: T) {
        unsafe {
            let node_size = core::mem::size_of::<Node<T>>() as size_t;
            let new_node = malloc(node_size) as *mut Node<T>;
            if new_node.is_null() {
                panic!("Failed to allocate memory for node");
            }

            // Initialize the new node
            (*new_node).element = elt;
            (*new_node).next = ptr::null_mut();
            (*new_node).prev = (*self).tail;

            // Update list pointers
            if (*self).tail.is_null() {
                // Empty list case
                (*self).head = new_node;
            } else {
                // Connect current tail to new node
                (*(*self).tail).next = new_node;
            }

            (*self).tail = new_node;
            (*self).len += 1;
        }
    }

    /// Adds an element first in the list.
    ///
    /// This operation should compute in *O*(1) time.
    ///
    /// # Safety
    ///
    /// This function is unsafe because it:
    /// - Dereferences a raw pointer (`self`)
    /// - Performs raw memory allocation
    /// - Dereferences memory that was just allocated
    ///
    /// The caller must ensure that `self` points to a valid `LinkedList<T>`.
    pub unsafe fn push_front(self: *mut Self, elt: T) {
        unsafe {
            let node_size = core::mem::size_of::<Node<T>>() as size_t;
            let new_node = malloc(node_size) as *mut Node<T>;
            if new_node.is_null() {
                panic!("Failed to allocate memory for node");
            }

            // Initialize the new node
            (*new_node).element = elt;
            (*new_node).next = (*self).head;
            (*new_node).prev = ptr::null_mut();

            // Update list pointers
            if (*self).head.is_null() {
                // Empty list case
                (*self).tail = new_node;
            } else {
                // Connect current head to new node
                (*(*self).head).prev = new_node;
            }

            (*self).head = new_node;
            (*self).len += 1;
        }
    }

    /// Provides a pointer to the back element, or `None` if the list is
    /// empty.
    ///
    /// # Safety
    ///
    /// This function is unsafe because it:
    /// - Dereferences a raw pointer (`self`)
    /// - Returns a raw pointer to the element
    ///
    /// The caller must ensure that `self` points to a valid `LinkedList<T>` and that
    /// the returned pointer is not used after the element or list is dropped.
    pub unsafe fn back(self: *const Self) -> Option<*mut T> {
        unsafe {
            if (*self).tail.is_null() {
                None
            } else {
                Some(&raw mut (*(*self).tail).element)
            }
        }
    }

    /// Provides a pointer to the front element, or `None` if the list is
    /// empty.
    ///
    /// # Safety
    ///
    /// This function is unsafe because it:
    /// - Dereferences a raw pointer (`self`)
    /// - Returns a raw pointer to the element
    ///
    /// The caller must ensure that `self` points to a valid `LinkedList<T>` and that
    /// the returned pointer is not used after the element or list is dropped.
    pub unsafe fn front(self: *const Self) -> Option<*mut T> {
        unsafe {
            if (*self).head.is_null() {
                None
            } else {
                Some(&raw mut (*(*self).head).element)
            }
        }
    }

    /// Removes the last element from a list and returns it, or `None` if
    /// it is empty.
    ///
    /// This operation should compute in *O*(1) time.
    ///
    /// # Safety
    ///
    /// This function is unsafe because it:
    /// - Dereferences a raw pointer (`self`)
    /// - Modifies the list's structure
    /// - Frees memory
    ///
    /// The caller must ensure that `self` points to a valid `LinkedList<T>`.
    pub unsafe fn pop_back(self: *mut Self) -> Option<T> {
        unsafe {
            if (*self).tail.is_null() {
                return None;
            }

            let old_tail = (*self).tail;
            let prev = (*old_tail).prev;

            // Update tail pointer
            (*self).tail = prev;

            if prev.is_null() {
                // The list is now empty
                (*self).head = ptr::null_mut();
            } else {
                // Update the new tail's next pointer
                (*prev).next = ptr::null_mut();
            }

            // Retrieve element before freeing the node
            let result = ptr::read(&(*old_tail).element);

            // Free memory and update length
            free(old_tail as *mut c_void);
            (*self).len -= 1;

            Some(result)
        }
    }

    /// Removes the first element and returns it, or `None` if the list is
    /// empty.
    ///
    /// This operation should compute in *O*(1) time.
    ///
    /// # Safety
    ///
    /// This function is unsafe because it:
    /// - Dereferences a raw pointer (`self`)
    /// - Modifies the list's structure
    /// - Frees memory
    ///
    /// The caller must ensure that `self` points to a valid `LinkedList<T>`.
    pub unsafe fn pop_front(self: *mut Self) -> Option<T> {
        unsafe {
            if (*self).head.is_null() {
                return None;
            }

            let old_head = (*self).head;
            let next = (*old_head).next;

            // Update head pointer
            (*self).head = next;

            if next.is_null() {
                // The list is now empty
                (*self).tail = ptr::null_mut();
            } else {
                // Update the new head's prev pointer
                (*next).prev = ptr::null_mut();
            }

            // Retrieve element before freeing the node
            let result = ptr::read(&(*old_head).element);

            // Free memory and update length
            free(old_head as *mut c_void);
            (*self).len -= 1;

            Some(result)
        }
    }

    /// Returns `true` if the linked list is empty.
    ///
    /// This operation should compute in *O*(1) time.
    ///
    /// # Safety
    ///
    /// This function is unsafe because it dereferences a raw pointer (`self`).
    /// The caller must ensure that `self` points to a valid `LinkedList<T>`.
    pub unsafe fn is_empty(self: *const Self) -> bool {
        unsafe { (*self).head.is_null() }
    }
    ///
    /// # Safety
    ///
    /// This function is unsafe because it creates references from raw pointers.
    /// The caller must ensure that the LinkedList remains valid while the iterator is in use.
    pub unsafe fn iter(self: *const Self) -> Iter<T> {
        unsafe {
            Iter {
                current: (*self).head,
            }
        }
    }
}

impl<T> Iterator for Iter<T> {
    type Item = *mut T;

    fn next(&mut self) -> Option<Self::Item> {
        unsafe {
            if self.current.is_null() {
                None
            } else {
                // Get the current element value
                let element = &mut (*self.current).element;

                // Move to the next node
                self.current = (*self.current).next;

                // Return the current element
                let ptr = &raw mut (*element);
                Some(ptr)
            }
        }
    }
}

impl<T> Drop for LinkedList<T> {
    fn drop(&mut self) {
        unsafe {
            let mut current = self.head;

            while !current.is_null() {
                // First, capture the next pointer
                let next = (*current).next;

                // Move out the element using a direct mutable pointer to element
                // Move out the element using a direct pointer to element
                let element = ptr::read(&(*current).element);
                // Drop the element first
                drop(element);

                // Then free the node memory
                free(current as *mut c_void);

                // Move to next node
                current = next;
            }

            // Reset the list state
            self.head = ptr::null_mut();
            self.tail = ptr::null_mut();
            self.len = 0;
        }
    }
}

Pretty much like C. Except I have taken the liberty of implementing methods and traits (using raw pointer self parameters) and using generics. Which is not strictly necessary to demonstrate the C like situation.

I might argue that diverting our Rust students attention to C as a stepping stone to Rust is an unnecessary confusion and time waste. Same stuff, slightly different syntax. While with Rust we get the luxuries of a nice build system and many language features C is missing.

Start Rust students off with Crust :slight_smile:

Yes, again I agree. That is the top-down vs. bottom-up strategy known from software design. Both have advantages and disadvantages. I think it finally depends on the personality of the learner. One might like to start with high level concepts first, the other might like to start with plain basics, e.g. C even showing the generated assembly code.

Not strange at all. I think it's a great idea. Having first been taught BASIC in tech college in 1975. Since then a couple of generations of software engineers cut their teeth on BASIC with the arrival of 8 bit home computers. Now a days they can do it with Python or Javascript and such.

I was not particularly thinking of raw beginners to programming when I open this thread though. Rather all those coming from other backgrounds who could do without the diversion to C on the way whilst still getting the full C experience of segfaulst, core dumps and garbled results. And hence learning what the Rust borrow checker is all about.

Given how essential being able to print text is there is no escape from pointers even for the most basic introduction to C. As C does not actually have strings (except maybe string literals), only pointers to sequences of bytes. All the functions in the std library that have "str" in the name only try to create an illusion of strings and use pointer parameters.

And that is the thing I am attempting to convince you of with this thread.

You even can learn driving with a racing car, and learning flying with a Boing 747. :slight_smile:

Or learning cycling with a Honda CBX 6 stroke engine, instead with a plain bicycle.
Honda CBX - Wikipedia

Perhaps you could. I don't feel that those are good analogies to what I'm suggesting here. Kicking off a Rust student with Crust is nothing like as big, expensive or complicated as a racing car or 747. Just install the compiler and code. Like you do in C.

That was the analogy I had in mind, assuming it was ironic of course. People do have to learn on a small GA airplane before jumping to the airliner, and I assume the same is often true in racing (unless you can afford to buy a position as F1 driver, which some did...).

That's a little how I see the utility of learning another language before Rust. And perhaps C somewhere in the middle, depending on how smooth you want the learning curve to be.

I think your claim is a fine approach for University students in a field related to programming or computer science.

For the self-learner I see either:

  • fast and friendly way to go through rust but with superficial knowledge (such as in some short rust books.)
  • or a more complex, comprehensive but also slower learning process such as the official book provides.

There are other dimensions like the learner's learning style, or speed at digesting concepts, their goals and motivations.

In all cases, I think prior familiarity with programming languages and concepts is a requirement. Which you agreed to.

But again, for those with basic PL knowledge and in Uni, I think what you suggest is very reasonable. For the ones not at a Uni-path, it seems less useful. For those, I'd go with the book as it is, or a slim version.

1 Like

if you start programming from assembler, as I did, C looks trivial, as the assembler extension. But if I use Rust, most things are hidden from me. So, if you didn't learn asm, I would still recommend to eliminate the gap. C is an optional after.

I don't understand how university students or a university education is relevant. I'm not discussing a deep study of programming language design or compiler construction. I'm only talking about the need or lack of for suggesting people, with whatever education, learn C before Rust.

My experience over decades is that the majority of people writing software do not have CS degrees or formal education in programming. They may well have degrees in almost any other subject or never have been to uni. A few generations of programmers were self learners, starting with BASIC and assembler on their C64's and whatever.

I think you are right that people from experiences could do with different introductions to Rust. It's just that I don't see that C need be part of it for any of them when one can do all that C style stuff in Rust just as easily.

1 Like

I think that is right. At least it was experience that having used assembler before coming to C made what C does clear to me.

Here we are hitting on my point with this thread. If one uses the "Crust" style of Rust nothing is hidden. It's all very similar to C.

There are at least two points that make a significant difference. First, University students are in most cases nearly adult, I assume at least 16 years old or even older. But today people interested in computer technology at all start their own programming experiments often already at an age of 12 to 14. There exists a few exceptions of course -- people not really interested in computer science, who recognize that they might need some programming skills for the job, or retired people, who might learn programming for fun. But these two last categories would typically choose more a language like Python, and rarely Rust. So a Rust book addressed to absolute beginners should also address to 12 to 14 year old children. The second point is just, that university students might take the learning process more serious, spent more time for it, and are willing to continue eve when it is not always fun. As you know, I once wrote a beginner book for the Nim language, which mostly failed, so this is my experience.

I think it depends a lot on why someone is learning programming. If it's for for some specific domain, then start with the most relevant option. JavaScript is more relevant than C or Rust for websites, for example. A mathematician may want to start with Python, R or Matlab instead. If it's for something more general purpose, like a CS degree, C may still be relevant because of the amount of existing C code someone is likely to run into. Not because of the "learning what the machine does" argument. There's still a few abstractions left before you reach that level.

I think something everyone needs to go through when learning programming is to learn programmatic thinking and how to structure a program. That's possible in basically any conventional language, but I think the more"plug-and-play" and "no nonsense" (every language has some nonsense going on) it can be, while still staying relevant, the better. Even something like Scratch is excellent to just get going. The learner will be busy wrapping their head around functions and control flow in either case.

I think that if the learning material can achieve the appropriate level of simplicity, any language is sufficient. For Rust, you may have to front load a few extra concepts (the ! after println, integer sizes, basic error handling, ...), but no need to go particularly deep to get basic IO working and start experimenting. So if there's learning material and Rust is relevant, then why not start there?

An argument that's not necessarily relevant for learning is that if you already know C or C++ you may have a greater appreciation for why Rust is the way it is and where it's helpful. The same as what you may experience when going from assembler to C. That doesn't make it a requirement for learning.

1 Like

I think the original "C" book was so simple to understand. Easy enough for a teenager maybe I was special? that didn't even have access to a computer. Before I read that book, I tried languages and books before, Basic, Apl, Pascal. They all seamed strange. (Mind you I was a teenager and the only access to computers I had was Atari Basic, TI basic, later apple basic etc. ) Even Basic that I had access to seamed strange. I read the "C" book and it all clicked and I struggled trying to make my art with basically only access to Basic language. Finally getting access to a C compiler, I was saved.
Rust is a bit harder to understand / learn than "C". I don't think I could have made it through the Rust Book if I didn't know "C" before hand.

I think the TCPL book is very good and the C language are just so easy to learn. Beginners should give it a try so they understand the basics. When getting past trivial use of rust you start to hit "FFI", no_mangle, etc. If you know about "C" then that stuff is more easily understandable.

End of rant/

Answering the one who said that “it depends on what people study for” and “retired people study for fun”. Well actually I'm one of those people who learned low level languages.

When I was about 12 years old I made a website from HTML, without css. It was just ui. Since then I've gotten to know languages like CSS and Python. Until I went to college, I never finished all of that.

Then I was curious about Rust, the concept is really raw. Reading from The book and I don't like practice without material. Just understanding heap and stack took more than a week to get it right in my brain.

I continue until now and fill personal blog articles with my learning process of rust, this is fun. I guess from my experience point of view, learning C is just a choice. But for Uni it seems to be part of the curriculum.