this post was submitted on 18 Mar 2024
83 points (98.8% liked)

Transprogrammer

818 readers
1 users here now

A space for trans people who code

Matrix Space:

Rules:

founded 1 year ago
MODERATORS
 

I lived in a perfect OOP bubble for my entire life. Everything was peaceful and it worked perfectly. When I wanted to move that player, I do player.move(10.0, 0.0); When I want to collect a coin, I go GameMan -> collect_coin(); And when I really need a global method, so be it. I love my C++, I love my python and yes, I also love my GDScript (Godot Game Engine). They all work with classes and objects and it all works perfectly for me.

But oh no! I wanted to learn Rust recently and I really liked how values are non-mutable by defualt and such, but it doesn't have classes!? What's going on? How do you even move a player? Do you just HAVE to have a global method for everything? like move_player(); rotate_player(); player_collect_coin(); But no! Even worse! How do you even know which player is meant? Do you just HAVE to pass the player (which is a struct probably) like this? move(player); rotate(player); collect_coin(player, coin); I do not want to live in a world where everything has to be global! I want my data to be organized and to be able to call my methods WHERE I need them, not where they just lie there, waiting to be used in the global scope.

So please, dear C, Rust and... other non OOP language users! Tell me, what makes you stay with these languages? And what is that coding style even called? Is that the "pure functional style" I heard about some time?

Also what text editor do you use (non judgemental)? Vim user here

top 50 comments
sorted by: hot top controversial new old
[–] [email protected] 48 points 8 months ago* (last edited 8 months ago) (4 children)

If you want your code to be performant you need to think about how you lay out your data for your CPU to manipulate it. This case might work well for one player but what if you have 100, 10 000?

When you call player->move (assuming polymorphism), you're doing three indirections: get the player data at the address of player, get the virtual function table of that player, get the address of the move function.

Each indirection is going to be a cache miss. A cache miss means your cpu is going to be waiting for the memory controller to provide the data. While the cpu can hide some of this latency with pipelining and speculative execution, there are two problems: the memory layout limits how much it can do and the memory fetch is still orders of magnitude slower than cpu instructions.

If you think that's bad, it gets worse. You now have the address of the function and can now move your player. Your cpu does a few floating point operations on 3d or 4d vectors using SIMD instructions. Great! But did you know that those SIMD registers can be 512 bits wide? For a 4d vector, that's 25% occupancy, meaning you could be running 4x as fast.

In games, especially for movement, you should be ditching object oriented design (arrays of structs) and use data oriented design (struct of arrays).

Don't do

struct Player { float x, float y, float rotation, vec3 color, Sprite* head};
Player players[NUM];

Instead do

struct Players {
    Vec2 positions[NUM];
    float rotations[NUM];
    vec4 colors[NUM];
    Sprites heads[NUM];
};

You will have to write your code differently and rethink your abstractions but your CPU will thank you for it: Less indirections, operations will happen on data on the same cache lines, operations will be vectorizable by your compiler and even instruction cache will be optimized.

Edit 1: formatting

Edit 2: just saw you're doing 2d instead of 3d. This means your occupancy is 12.5%. That operation could be 8 times as fast! Even faster without indirection and by optimizing cache data locality.

[–] [email protected] 8 points 8 months ago (2 children)

Is it possible for a particularly smart compiler to redo this all as if it were data-oriented? Sorry if that's a silly question.

[–] [email protected] 8 points 8 months ago* (last edited 8 months ago)

Not a silly question at all!

Compilers are already really smart and do a lot of heavy lifting but they're also restricted to what you write and they err on the side of safety. They will do things like inline object functions if you don't have virtual functions and are simple enough which reduces the number of indirections. They won't re-order your classes and re-write your code. In my experience compilers don't do a good job at magically vectoring code (using SIMD registers to their fullest extent), so maybe that can be improved by a super smart compiler.

I would say it's possible to have a linter let you know if you're making structs which are cache unfriendly.

There are also runtime tools like Intel's Vtune or perf on Linux. I would say that while those tools are very powerful the learning curve is very difficult. In my experience you need to know a lot about optimization to understand the results.

Today's generative AI can give you broad strokes about refactoring some code to DOD and I'm sure in a few years it could do something to whole projects.

Oftentimes safety comes at the cost of performance with compilers if you don't give it enough details such as restrict/noalias, packing, alignment, noexcept, assume/unreachable, memory barriers. Rust is able to be performant and safe because it is a very verbose and restrictive language when you write it. C++ gives you all the tools but they tend to be off by default. In my experience game devs like to stick to C++ despite the lack of safety guardrails because it's faster to write efficient code and "we're not making medical equipment" sentiments.

[–] [email protected] 4 points 8 months ago

I think at that point you could just look into Entity Component System design. I'm particularly fond of Flecs. Here, entities are empty objects to which you can add any number of components. Typically components are void of logic. Instead you write systems that match entities that have the components they need, and then just operate on that data.

[–] [email protected] 3 points 8 months ago (1 children)

I thought about this a few times while programming but general advice pointed me towards OOP so I never actually got to implement anything in Data Oriented Programming the way you described.

[–] [email protected] 2 points 8 months ago

Me either. This was enlightening.

load more comments (2 replies)
[–] [email protected] 31 points 8 months ago (2 children)
impl Player {
    fn move(&mut self, x: f64, y: f64) { ... }
}

player.move(10.0, 0.0);
[–] [email protected] 6 points 8 months ago (1 children)

Where would we define the player position?

[–] [email protected] 9 points 8 months ago (1 children)

You'd use a struct like

struct Player {
    x: f64,
    y: f64,
}
[–] [email protected] 2 points 8 months ago (5 children)

Oh. So we would have the methods and data in seperate parts? Or can we combine the Player impl and the Player struct and use them as one?

[–] [email protected] 5 points 8 months ago

The struct Player and impl Player works as a class, with the difference that the struct block defines the attributes, e.g. position, and the impl defines the methods, e.g. move operation, as you figured out.

What Rust does not have is inheritence like you do for classes, instead you have traits.

Say you have a vector class. You will need some objects of rotating vectors, some objects of translation vectors, and some objects that can do both rotation and translation. You do a subclass of this vector class for rotating vectors, you make another subclass for translating vectors... you know what? Maybe all rotating vectors have to be also translating vectors, because you sometimes need a subclass which needs both. Ok. Or something like that might be your solution.

In Rust you'd instead define a rotation and translation trait, by saying structs dressed in this trait must have a method taking this and that argument, returning this and that. You make an impl block defining what these methods should look like for your vectors. You can dress one struct in several traits, so you have one struct which you give both the rotation and translation trait.

The bonus with traits now is now that later you realize that, ah, not only my pure vectors needs to be able to rotate. My Car struct needs to rotate, or my Planet struct, whatever. Great, you have a trait you can give to those structs and it will have the same methods as your rotating pure vectors.

I'm not a proper programmer, so this may all be a bit misleading. This is just how I think about it when I'm using Rust.

load more comments (4 replies)
[–] [email protected] 6 points 8 months ago (1 children)

Sooo impl is some kind of kinda class? It can carry data and methods, and what can it not?

[–] [email protected] 12 points 8 months ago* (last edited 8 months ago) (1 children)

Consider struct as the data layout / organization, and impl (of that struct) as the functions & implementation of functionality for structs (and traits). It's basically like separating member variables & member functions.

[–] [email protected] 3 points 8 months ago (1 children)

So they are seperated into the method part and the data part, hm? Can we access them when giving them the same name? So if we have an impl and a struct of the same name, can use it the same? Like this: let mut player = Player(); player.move(vec2(10.0, 0.0)); player.position += vec2(10.0, 0.0); Or would this work differently?

[–] [email protected] 2 points 8 months ago

They're separate blocks, but they're talking about the same type. struct deals with the data, impl deals with associated functions/methods/constants. If you implement a trait, you'd write yet another block like impl Trait for Player { [the stuff required by the trait, like an interface]}

trait Position {
    fn get_pos(&self) -> (f64, f64);
}

struct Player {
    x: f64,
    y: f64,
}

impl Player {
    const SOME_CONSTANT: usize = 42;
    fn not_associated_with_trait(&mut self) {
        self.x += 1.0;
    }
}

impl Foo for Player {
    fn get_pos(&self) -> (f64, f64) {
        return (self.x, self.y);
    }
}
[–] [email protected] 23 points 8 months ago

Late response and you might have already gotten an answer, but what you wrote is exactly the same as:

// Define our player struct 
struct Player {
     x: f32,
     y: f32,
     rotation: f32
}
// Define the methods available to the player struct 
impl Player {
     pub fn move(&mut self, x: f32, y: f32) {
          self.x += x;
          self.y += y;
     }

     pub fn rotate(&mut self, by: f32) {
           self.rotation += by;
     }
}

fn main() {
    let mut player = Player { x: 0.0, y: 0.0, rotation: 0.0 };
    player.move(10.0, 10.0);
    player.rotation(180.0);
}

The code example you wrote does not use anything that is exclusive to OOP languages as you are simply encapsulating values in a class (struct in the Rust case).

Unlike C++, the biggest difference you will find is that Rust does not have the same kind of inheritance. In Rust you can only inherit from traits (think interfaces in Java/C# or type classes if you have ever used Haskell), whereas in C++ and other OOP languages you can also inherit from other classes. In a lot of cases just using traits will suffice when you need inheritance. :)

So in conclusion, no global functions! You still have the same name spacing and scoping as you would in C++ etc!

Ps. I use VScode because it rocks with Rust, and while Rust is heavily inspired by functional programming languages, it is not a pure functional programming language (nor is C) but that is another can of worms.

[–] [email protected] 13 points 8 months ago

Somebody needs to RTFM ;) no seriously, Rust isn't something you can just jump into and guess what you're doing. Start with the official book and make sure you understand all of that.

IME the hardest part of Rust was learning the lingo to interpret compiler messages, and getting a solid grasp on references and borrowing. There is a lot more of course, like any language, but to me that was the steepest learning curve. I haven't used it in a few years tho, after losing all interest in programming.

[–] [email protected] 13 points 8 months ago (2 children)

Your OO languages at their core just abstract patterns like using a *this pointer. OO is possible in any language once you understand how it works. You should just go back to C++ or whatever you’re comfortable with.

[–] [email protected] 19 points 8 months ago* (last edited 8 months ago) (1 children)

You should just go back to C++ or whatever you’re comfortable with.

I wouldn't want to discourage people from learning new languages

[–] [email protected] 11 points 8 months ago (1 children)

Yeah, I wanna learn Rust cuz of safety and stuff.

[–] [email protected] 2 points 8 months ago (1 children)

You should of course do as you like. It’s just that if OO is a real feature you require then you should choose a language with that feature. But tbh you should chose a real OO language like Ruby.

[–] [email protected] 4 points 8 months ago (2 children)

What's not real about C++? I do want to use a compiled language for now, as I have toyed around with python and GDScript for a bit too long now. I wanna write some faaast code so I can do things unneccesarily quickly.

[–] riskable 3 points 8 months ago* (last edited 8 months ago)

You want speed? Rust is a good choice. Probably the best choice based on the objective benchmarks and more subjective things like ease of making your code multi-threaded.

Also, many would argue that "Rust is the future." Now that I know Rust, C and C++ seem like old, crufty things that annoy me if I'm forced to use them 🤷

[–] [email protected] 2 points 8 months ago

It’s quite real but if you really wanted an OO experience you’d choose something like Ruby. Really check it out you may find you like it.

[–] [email protected] 15 points 8 months ago (1 children)

Methods are just functions that take in a struct self pointer.

[–] [email protected] 3 points 8 months ago (2 children)

Hm.. Never though about it that way. I guess that really is how they work, don't they?... But it's all cool and combined in OOP so it works so nicely and stuff.

[–] [email protected] 2 points 8 months ago

That's because Java and C don't make it explicit. Python and Golang and others do.

load more comments (1 replies)
[–] riskable 10 points 8 months ago (1 children)

When you call player.move() are you mutating the state of player or are you really just logically attaching the move() function to the player object in order to keep your code... Logical?

player could actually be an interface to some object in an external database and move() could be changing a value in that database. Or player could just be a convenient name and place for a collection of "player"-related functions or stranger (yet weirdly common): A workaround for implementing certain programming patterns (Java and C#, I'm looking at you haha).

In Rust, attaching a function or property is something you do to structs or enums. It carries a very specific meaning that's much more precise and IMHO more in line with the original ideals of OOP (what they were trying to accomplish) and I think the way it's implemented (with traits) makes it far more flexible.

You can define a trait that requires a bunch of types/functions and then any implementation (impl) of a struct or enum that includes them can be said to support that trait. This allows you to write type-safe code that will work in zillions more situations and across many different architectures than you could with traditional OOP languages like C++ or Java.

It's the reason why embedded rust is kind of taking the world by storm right now... You really can "write once, run everywhere" thanks to careful forethought from the Rust developers and embedded-hal.

[–] [email protected] 5 points 8 months ago

In my case I want to move that player, meaning, changing the position of that player object (probably gonna be a vec3 or vec2). So like this:

void move(vec2 by){
    this -> position += by;
}

I will look into impl. They do seem very useful from what I have heard from the other commenters on here. Thank you for contributing and sharing your knowledge!

[–] [email protected] 9 points 8 months ago* (last edited 8 months ago) (1 children)

Ever since I learned Clojure, I’ve ridden the functional programming train. Now I write Elixir for my day job and even though I still have a soft spot for Java, the first language I wrote professionally, I think OOP in general is a flawed paradigm that makes bad software. But I won’t rant about it, I know these things can be a matter of taste for a lot of people.

In a functional language like Elixir, each function belongs to a module, which is just a namespace that lives in its own file. You just call a function with the module prefix, like

MyApp.Accounts.register_user(“[email protected]”)

There’s no inheritance, though there is polymorphism via something called Protocols. This makes it trivial to find the actual code you’re executing, which makes it so easy to debug stuff.

There are primitive data types, like integers, floats, and binary blobs (and strings are just binaries that are expected to be UTF-8), and then simple data structures like lists and maps. You can define structs, which are just maps with keys you define at compile-time.

I find that this leads to code that is way, WAY easier to design, write, read, and debug. I’m never stressing over trying to find the perfect abstraction for whatever I’m trying to write. I just write the function that does the thing I want. And you don’t need to remember a hundred different “design patterns,” either. There are a few simple patterns like map and reduce, and those are still just functions that transform data.

[–] [email protected] 3 points 8 months ago (1 children)

Ok I'm not that into programming yet, what is a namespace? I've seen it in some C code, where it says "using namespace std" for some IO stuff like cout and cin.

[–] [email protected] 6 points 8 months ago

I’m using in the generic sense, as a bucket of function names. It’s kind of like how a class is a namespace for the methods defined on it. Two different classes can have a method with the same name, but you can’t define two methods with the same name & same args on one class.

[–] [email protected] 9 points 8 months ago

I don’t program in Rust, but IMO non-mutable by default is how it should’ve always been. It’s more reasonable to make values mutable out of necessity - not make them constants just because you can. Even in OOP I think you should avoid using variables when possible, as they commonly give rise to logical errors.

I think it’s harder to reason around programs that heavily use variables. It’s easy to tangle yourself into a mess of spaghetti code. You need to read back and forth to understand all the possible states the program can be in and ensure none of these states will break it. “Oh, you can’t call this method on line 50 because some other method call on line 40 changed some internal value, which isn’t corrected until line 60”.

Same code without variables is usually easier to read. There’s only one state to consider. You just read the code from top to bottom and that’s it. Once a value is set, then that’s final. No surprise states.

Variables also tend to make multithreading more difficult to reason about.

Your example with player movement is one example where variables are needed. You should keep using mutables here.

I think all programmers should learn to program in a more functional style. Even if you end up using OOP you can still make use of functional programming practices, like avoiding variables.

[–] [email protected] 9 points 8 months ago* (last edited 8 months ago) (1 children)

The only thing that makes rust different from cpp is the lack of inheritance. We have classes, they are called structs. And Interfaces, they are called traits.

But instead of inheritance if you want shared behavior between two structs you need to have both of them implement the same trait. So instead of

fn pet(aimal: Animal)

You'd have

fn pet(animal: impl Petable)   // polymorphism via monomorphization

Or

fn pet(animal: &dyn Petable)  //polymorphism via dynamic dispatch

Instead of writing an animal super class you define a Petable trait:

trait Petable{
    fn pet(){}
}

We even have operator overload, because you can simply implement the f32::Add (or whatever) trait for your structs.

[–] [email protected] 1 points 8 months ago

std::ops::Add my beloved

[–] [email protected] 6 points 8 months ago

I still use C for embedded device programming and it's really just about splitting code into separate files by what they do if an app ever gets too big.

If you really need something OOP'ish, you can use function pointers inside of a struct. You would be lacking the OOP syntax of C++, but it's fundamentally similar.

When you are counting bytes for firmware, it's helpful to have a language like C. In theory, it limits code complexity and is much easier to estimate what is going to be shat out of the compiler. Honestly, byte counting is super rare for me since there is just so much program space on devices these days. (If I did any work with ATTiny MCUs, I would probably coding in .ASM anyway...)

While I don't code in Rust (yet), I still think it makes perfect sense not to leverage classes. My limited experience in *lang languages taught me that simple functions are perfect for heavy parallelization. By restricting the number of pointers you are tossing around and using only immutable values, the surface area for failure is drastically reduced. (This is also awesome for memory safety as well.)

Just remember that all languages are tools and you should use the tools that fit the job. Efficiency should always be top of mind and not the nuances of a language. (I grew up learning how to conserve CPU ticks, so that should explain my point of view.)

[–] [email protected] 6 points 8 months ago* (last edited 8 months ago)

While at first, Rust's lack of inheritance threw me off, I've found that traits do plenty of heavy lifting in that department.

Edit: Also, you can make class-like accessors and functions with impl blocks. Is that what you mean?

[–] [email protected] 4 points 8 months ago (8 children)

My dear friend - what if I told you that every call to Player.move should return an entirely new instance of a Player? One with an immutable position, and a helper function that takes a position delta - and constructs yet another Player!

What if I told you that all user interfaces are a function of application state; and all interactions apply a transformation that is then re-rendered? (We have gotten very good at only re-rendering the parts that change.)

Welcome to FP! There’s a whole world here for you to explore. You’ll be telling your friends about monoids and endofunctors before you know it :)

load more comments (8 replies)
[–] [email protected] 4 points 8 months ago* (last edited 8 months ago)

I use helix as my editor. It's vim-like and great for rust right out of the box with no configuration. So much so that it replaced my 300+ line 20+ plugin neovim configuration with 1 line of toml (to set the theme). It's also written in rust :3

[–] [email protected] 2 points 8 months ago

Absolutely no problem. I've done decades of programming in C, and it's absolute fine. For a bigger project, you need discipline, yes, but there are bigger projects in C out there that prove that this can be done.

Actually, my error rate is way below that of my coworkers who do C++ and C#, despite that I'm working directly on the iron (i.e. there is no OS between me and the processor, no interprocess protection, or similar).

[–] [email protected] 2 points 8 months ago

I only use C when I very likely don't need classes, and if I then still need to, I can fake them more or less well with structs, functions and pointers to functions in structs.

load more comments
view more: next ›