Nano Memories
26 aug 2026
human

Rust is safe C

I often see Rust compared to C++ in a negative way, criticizing the language design as a whole and portraying it as just as complex and overloaded as C++. I also see C being presented as a good example: a minimalist language that is easy to learn and does not require years to master.

I cannot disagree with the latter statement. Rust is indeed much more complex than C. I would never recommend learning Rust as a first programming language (just like C++). I consider this characteristic quite telling.

Nevertheless, I consider Rust much closer to C than to C++ in terms of language design. And I do not think so without reason - I write Rust with exactly this idea in mind. In this article, I will describe my main points and compare the two languages, looking at their similarities and contrasting them with C++ and similar "OOP languages".

If I had to summarize my approach, I would describe it as follows: Rust is memory-safe C, with a borrow checker and ownership. It is relatively simple and does not have an overloaded foundation. In many ways, Rust is even simpler than C# or Java.

So, what makes Rust simple?

No Classes

Let's start with data types. In Rust, just like in C, there are no classes. Instead, the usual way to define a type is to create a struct:

struct Book {
    title: String,
    pages: usize,
}

impl Book {
    fn new(title: String, pages: usize) -> Self {
        Self { title, pages }
    }
}

You may notice that the struct declaration and its associated new function are defined in separate blocks. Syntactically, this is similar to C's separation of data and functions that operate on that data:

#include <stdlib.h>
#include <string.h>

typedef struct {
    char *title;
    size_t pages;
} Book;

Book new_book(const char *title, size_t pages) {
    char *new_title = malloc(strlen(title) + 1);
    strcpy(new_title, title);
    return (Book) { .title = new_title, .pages = pages };
}

void delete_book(const Book *book) {
    free(book->title);
}

In class-based languages, on the other hand, it is common to define data and behavior in the same place. This fits better with the OOP philosophy, where an object contains both its data and its behavior. But syntax is only what lies on the surface. The interesting things are deeper.

Constructors are an integral part of the OOP world, but Rust does not have them either. The new function in the example above is just an ordinary function and can have any name (new is the conventional name, but it can be something else). The only difference from the C new_book is the namespace: in Rust, to call the function, you also specify the Book namespace:

let book = Book::new(/**/);

Strictly speaking, nothing prevents you from defining a constructor-like function outside the namespace, just like in C, but this way we follow the common coding style: such "constructors" are conventionally associated with the type.

But what is the fundamental difference from constructors in OOP languages? Constructors are not just functions, they are special functions with a number of requirements:

In Rust and C, instead, values can be constructed using a literal: Book { title, pages } or (Book) { .title = new_title, .pages = pages }. A constructor as a separate language construct is simply not needed. What is a special feature in OOP languages is just an ordinary function in Rust and C. Instead of constructor overloading, you can simply write other functions with different sets of parameters. In fact, Rust has no function overloading at all, just like C.

What really distinguishes Rust code from C is its memory-safe nature. In C, we also need to define "destructors" for objects, such as void delete_book(const Book *book) {/**/} - Rust does this automatically. Also, in Rust we almost never need to manually free resources, whereas in C this is necessary.

Another interesting difference is move semantics in Rust. In C, we need to manually copy data with strcpy(new_title, title), while in Rust we simply transfer ownership of the String. In fact, there is nothing preventing you from simulating similar move semantics in C: instead of copying, you could implicitly transfer "ownership" of the data to the Book struct - but this approach is non-obvious and is not expressed in the types in any way.

Methods

What if we need to define some behavior for a type? OOP languages introduce a separate category of functions - methods. In C all functions are simply functions. Instead of creating a method, we can just define a function and use a name starting with book_ to emphasize that the function is related to the Book type:

size_t book_title_len(const Book *book) {
    return strlen(book->title);
}

But in Rust, using namespaces and a special self parameter, we can define a function that looks very much like a method. In fact, in Rust these functions are simply called "methods" for convenience. I will discuss how they differ from class methods in OOP languages later.

impl Book {
    fn title_len(&self) -> usize {
        self.title.len()
    }
}

Syntactically, this differs from C only in how the function is called: we call it with book.title_len() instead of book_title_len(book). This mainly adds ergonomics. I can inspect all methods of an object in my code editor by typing ., then see the list of available methods. I can also write long chains of calls like o.proc().make().build() instead of build(make(proc(o))). If needed, the classic function call syntax is also available: Book::title_len(&book). In the end, we can remove unnecessary prefixes like book_, since methods are already placed in the type's namespace.

But despite the syntactic differences, methods in Rust are still just functions. How is this different from class methods? Let's consider an example of an abstract OOP language. Suppose I want to call a method on an object:

void printTitleLen(Book book) {
    print(book.getTitleLen());
}

As a result, should the .getTitleLen() method of the Book type be called? Yes.. but not necessarily. OOP languages have inheritance, and methods can be overridden (not always: Java has final methods, while in C++/C# a method must be virtual to be overridden, etc.). By default, the semantics of the code above are not "call the Book::getTitleLen function", but rather "perform a virtual call to the getTitleLen method on an object of type Book, or some other type that inherits from Book". This fundamentally distinguishes Rust/C from other languages where method overriding exists in some form.

No Inheritance

In Rust, just like in C, you cannot inherit types. And you do not need to. For example, to "extend a type" by adding new data, composition is used:

struct AuthorsBook {
    book: Book,
    author: String,
}

To get all methods of Book, we can also implement Deref. This way, functions and methods that work with &Book can also be called with an &AuthorsBook parameter. There is no magic or virtual dispatch involved here, authors_book.title_len() will simply be equivalent to authors_book.book.title_len() (while the book field itself can be private). This kind of composition is just as simple and minimalistic as in C.

However, in OOP languages, inheritance is often used as one of the mechanisms for polymorphism. More on that later.

Dynamic Polymorphism

For example, we have a convenient function for printing a book to the console, book_print:

void book_print(const Book *book) {
    printf("%s [%zu pages]\n", book->title, book->pages);
}

int main(void) {
    Book book = new_book("The C programming Language", 270);
    book_print(&book);
    delete_book(&book);
    return 0;
}

We create a book, call the function, and see the text The C programming Language [270 pages] in the terminal. Great! But what if we want to print different objects of different types? This is exactly the problem solved by what is called polymorphism.

So how is polymorphism expressed in C? The classic approach is a virtual function table:

typedef struct {
    void (*print)(const void*);
} PrintVTable;

void print(const PrintVTable *pvt, const void *obj) {
    pvt->print(obj);
}

And here the magic happens: the static types are erased, and the print function can somehow work with objects of any type. All we need is to provide a sensible PrintVTable implementation for the given object. We already have the book_print function, and we can pass a pointer to this function to the table by creating static PrintVTable BOOK_PRINT = { .print = book_print }; - but there is an important detail: due to type erasure, the polymorphic print takes the object as const void *obj. To work with this signature, we need to slightly rewrite book_print:

void book_print(const void *obj) {
    const Book *book = obj;
    printf("%s [%zu pages]\n", book->title, book->pages);
}

static PrintVTable BOOK_PRINT = { .print = book_print };

int main(void) {
    Book book = new_book("The C programming Language", 270);
    print(&BOOK_PRINT, &book); // The C programming Language [270 pages]
    delete_book(&book);
    return 0;
}

This produces a similar result to calling book_print directly. Essentially, we are calling the same function, just through an additional pointer.

Now the exact same example in Rust:

trait Print {
    fn print(&self);
}

impl Print for Book {
    fn print(&self) {
        println!("{} [{} pages]", self.title, self.pages);
    }
}

fn print(obj: &dyn Print) {
    obj.print();
}

fn main() {
    let book = Book::new("The C programming Language".to_owned(), 270);
    print(&book); // The C programming Language [270 pages]
}

We define the Print trait, similarly to how we defined PrintVTable. This will be our polymorphic interface. A separate trait implementation for the type, impl Print for Book, is analogous to creating static PrintVTable BOOK_PRINT = { .print = book_print };. The polymorphic function fn print(obj: &dyn Print) - as you can see, it takes a parameter of type &dyn Print, but what is that? In Rust, a dyn object is a pointer to the object + the vtable for the interface implementation. This corresponds to the C signature void print(const PrintVTable *pvt, const void *obj), except that the pointer and vtable are passed together as a single parameter rather than two separate parameters as in C. This reflects Rust's memory-safe nature: in C, we have to work with untyped void *obj pointers, and we must make sure that the type of the object passed matches the function table provided. In Rust the table is created automatically and provides full type safety.

As a result, the print function will work with objects of different types, requiring only an implementation of the Print trait:

struct Cat { name: String }

impl Print for Cat {
    fn print(&self) {
        println!("a cat named {}", self.name);
    }
}

fn print(obj: &dyn Print) {
    obj.print();
}

fn main() {
    print(&Cat { name: "Nano".to_owned() }); // a cat named Nano
}

The fundamental point that brings Rust and C closer together compared to other languages is that the pointer to the virtual function table is provided alongside the pointer to the object, rather than being stored inside the object as in C++/C#/Java, etc.

In Rust/C, two pointers are passed separately to the polymorphic function:

book   ─> [ title, pages ]
vtable ─> [ print ]

In OOP languages, a single pointer to the object is passed to the function, and the object itself contains a pointer to the virtual function table. With our Book type, it would look like this:

book ─> [ vtable, title, pages ]
          │
          └─> [ print ]

The design of Rust and C deliberately keeps data and behavior separate, while OOP languages, following their own philosophy, combine them instead. This leads to an interesting consequence: you can define any behavior for any types, even types that were not originally intended for polymorphic use:

void int_print(const void *obj) {
    const int *i = obj;
    printf("%d\n", *i);
}

static PrintVTable INT_PRINT = { .print = int_print };

int main() {
    int x = 67;
    print(&INT_PRINT, &x); // 67
    return 0;
}

The same applies to Rust:

impl Print for i32 {
    fn print(&self) {
        println!("{self}");
    }
}

fn main() {
    let x = 67;
    print(&x); // 67
}

On the other hand, in OOP languages you cannot simply define new behavior for an existing type (although C# has extension methods, and some languages support monkey patching). You either have to implement the functionality through inheritance (which requires changing the class code), or create a new type.

Static Polymorphism

So far, we have looked at what is called dynamic polymorphism. The fn print(obj: &dyn Print) function works dynamically with any reference to an object that implements the Print trait. But Rust also has generics - they allow us to express static polymorphism:

trait Print {
    fn print(&self);
}

impl Print for Book {
    fn print(&self) {
        println!("{} [{} pages]", self.title, self.pages);
    }
}

fn print<P>(obj: &P)
where
    P: Print,
{
    obj.print();
}

fn main() {
    let book = Book::new("The C programming Language".to_owned(), 270);
    print(&book); // The C programming Language [270 pages]
}

The result is similar to the example above, but what is the difference?

Now the print function takes a generic parameter <P>. When it is called, a concrete type must be statically substituted for P, which in our case is Book. At compile time, a specialized version of print will be generated that takes Book directly as its argument:

fn print_book(obj: &Book) {
    obj.print();
}

fn main() {
    let book = Book::new("The C programming Language".to_owned(), 270);
    print_book(&book); // The C programming Language [270 pages]
}

In this case, the compiler will not build a virtual function table under the hood and use it when calling .print(), as it does with dyn Print. Instead, Book::print is called directly without an additional level of indirection. One advantage is that this can be optimized much better because the compiler knows the concrete type for which it needs to generate the code. This also means that the compiler cannot immediately generate code for a generic function when compiling a crate. Instead, it has to keep the generic code and compile it only when it is instantiated (possibly from another crate), knowing the concrete type being substituted. This also comes with disadvantages: compilation takes longer, and the resulting binary can become larger because the function may need to be generated separately for each new type.

C has no generics/templates (_Generic aside), but a similar mechanism for code templating can be emulated using macros. In my opinion, this is a major omission in the language. Code templating can be useful - just imagine a type-safe HashMap in C that statically knows the types of its keys and values, all without macros 😖

But what is dyn actually needed for in Rust, besides saving compilation time and binary size?

Sometimes you need to work with values of different types that share a common trait implementation. A good example is storing references to values of different types in the same array/vec:

let book = Book::new("The C programming Language".to_owned(), 270);
let nano = Cat { name: "Nano".to_owned() };
for obj in [&book as &dyn Print, &nano] {
    obj.print();
    // The C programming Language [270 pages]
    // a cat named Nano
}

This is similar to cases where we need the void* type in C, except that in Rust this is implemented in a completely memory-safe and type-safe way. Nevertheless, if we set aside the requirements of memory safety, the two approaches are similar in Rust and C.

No Function Overloading or Default Parameters

In Rust, just like in C, functions cannot be overloaded:

fn proc(x: i32) {}
fn proc(x: i32, y: i32) {} // error: the name `proc` is defined multiple times

Also, there is no support for default parameters:

fn proc(x: i32, y: i32 = 0) {} // error: parameter defaults are not supported

All of this eliminates a lot of headaches and makes the languages simpler and more minimalistic 😖

No Exceptions

Rust has no exceptions. To signal errors, it is customary to return some special value, usually represented by the Result type:

fn proc(x: i32, y: i32) -> Result<i32, Error> {
    if y == 0 {
        Err("division by zero".into())
    } else {
        Ok(x / y)
    }
}

This is similar to the approach in C. We cannot throw an exception, so instead we return some special value (for example, -1), null, or an error code.

But Rust has panics, doesn't it? Aren't those the same as "exceptions"?

Although exceptions and panics use a similar mechanism - they unwind the stack - they are fundamentally different in terms of semantics. Exceptions are designed to be caught. Panics are not. A panic can be caught if desired (see catch_unwind), but not to "catch" an error value. Rather, it can be used to prevent the entire process from terminating when some bug occurs, allowing the error to be logged and execution to continue. Moreover, stack unwinding can be disabled entirely for panics, in which case they cannot be caught at all. Instead, a panic immediately terminates the process. For example, to disable stack unwinding in a release build, you can put the following in Cargo.toml:

[profile.release]
panic = "abort"

This also allows for more optimizations and makes the resulting binary smaller. The extra code for "what to do when a panic occurs" is simply removed from the build, making the resulting binary almost as lightweight as in C. Therefore, I recommend using panic = "abort" whenever possible.

Is Rust Still More Complex Than C?

There are two aspects to the "complexity" of a language: how easy it is to learn and how easy it is to write code in. C is a simple and minimalist language, so it is easy to learn. But writing code in it is difficult: you need to know many subtleties of memory management and many pitfalls to avoid undefined behavior, etc.

Rust is also difficult to write, because you need to get to grips with the borrow checker and statically prove the correctness of your program. In fact, you need to do this in C as well. It is unlikely that you write C code thinking, "yes, there is undefined behavior everywhere, that's exactly what I want". After all, correctness and memory safety of the final code are almost always required. One way or another, you need to prove (at least to yourself) that the code you wrote is correct. The difference is that in unsafe languages you can ignore this and simply test the external behavior of the program, which does not guarantee the absence of undefined behavior - it may remain hidden or manifest itself later. The Rust compiler forces you to verify the code immediately, which is why writing code in it feels more difficult.

But how easy is Rust to learn? If we set aside the specifics - the borrow checker, lifetimes, and ownership - it turns out that there are not that many features in Rust that need to be learned. The language remains relatively simple, without piles of unnecessary features and syntactic sugar. Even compared to C, Rust can be simpler in some areas. In particular, Rust does not have:

But C is still less overloaded with features compared to Rust. Rust has traits, const, async, more complex macros, iterators, closures, a more complex enum, etc. Nevertheless, all of these features are organically integrated into the language and actively used in practice, rather than being clutter or unnecessary syntactic noise. Many OOP languages can be just as or even more overloaded, despite having automatic memory management.

Of course, Rust is not a simple language by itself. But its complexity is largely concentrated around a few fundamental mechanisms, such as the borrow checker and ownership. Beyond those, Rust's design is indeed closer to C in many ways: data is separated from behavior, there are no classes or inheritance, there is no function overloading, there are no exceptions, and polymorphism is expressed through separate mechanisms - traits and generics.