Hacker News new | ask | show | jobs
by systems 1121 days ago
I don't think it matters how good or bad C# is, Object Oriented Programming is a mess

Learning, how to use an Object System (a tree of objects/classes) is inherently hard

The current problem with F# is that it doesnt do enough to shield you from objects, it does what it can, but still to use F# effectively, you still need to learn some C# and a lot of API that basically Objects inside Objects inside Objects calling Objects calling Objects and more Objects

OOP is bad because eventually OO systems becomes too complex, OO API is intimidation

Separating Data from Behavior manages complexity better

If the only flaw in C# is knowing which method calls requires the new keyword because its a constructor, and which dont because its a factory, that is bad enough to want to avoid it

8 comments

> OOP is bad because eventually OO systems becomes too complex, OO API is intimidation

This strikes me as a sort of ... reverse of survivorship bias.

You look around and see all complex systems are in OO, then you conclude that it is OO that is the cause of the complexity.

Have you considered that the non-OO designs are deficient in some way that prevents them from being used for the type of systems that you find to be examples of OO being bad?

Not that I am defending OO, I just want to know how you are differentiating between "OO produces complex systems" and "OO is used for complex systems".

> Have you considered that the non-OO designs are deficient in some way that prevents them from being used for the type of systems that you find to be examples of OO being bad?

Having shipped both significant non-OO projects and significant OO projects, their drawbacks were usually related to low adoption. In terms of code and architectural complexity, they were either comparable to OO projects (in specific situations), or better.

That being said, in most situations, language/paradigm choice were not the main drivers of project success. At worst, a bad OO codebase is a drag, not a killer, and the same is true with non-OO projects.

> Not that I am defending OO, I just want to know how you are differentiating between "OO produces complex systems" and "OO is used for complex systems".

OO definitely produces complex systems. And, let me be clear, by OO I mean the social consensus in OO circles, not the paradigm itself or the technical tools. My take is that OO circles host a cottage industry of consultancies and gurus peddling a stream of design patterns, advices, etc. which end up layering in any long-lived OO codebase and create unnecessary complexity.

>OO definitely produces complex systems. And, let me be clear, by OO I mean the social consensus in OO circles, not the paradigm itself or the technical tools. My take is that OO circles host a cottage industry of consultancies and gurus peddling a stream of design patterns, advices, etc. which end up layering in any long-lived OO codebase and create unnecessary complexity.

This right here. Every time I hear mid level dev bring up DDD I contemplate quitting and spending some time looking for a Rust or Clojure gig. Sometimes it gets so bad I think about biting the bullet and going to node.js

C# isn't a bad language, even the frameworks are taking a nice turn towards simplification (eg. ASP.NET Minimal APIs, EF direct SQL queries) but the culture it creates... LAYERS of bullshit :D

Absolutely! It is the misunderstanding and use of heavy abstraction, with "a class per file" that blows these systems into liabilities rather than solutions. Start with a low number of abstractions, as few as you can get away with given your requirements, and then only expand when the requirements change. It really doesn't matter the paradigm, it's possible to heavily abstract a functional system with various transformative functions that aren't truly needed until the data becomes more complex.

There is a whole industry peddling OO systems that are extremely abstracted for the benefit of filling chapters in a book, or producing extra pages of content in a website. I fell victim to both early on in my knowledge and even professional world, but somehow managed to follow what "felt right" and broke away from that to find an easy path forward that allowed me to use the tools I was given in the easiest way possible, and only introduce complexity when the solution was complex (not for the sake of complexity for complexity's sake).

I do feel like OOP introduces a lot of inherent overhead, not necessarily "complexity". I feel like doing anything in Java, for example, typically requires the creation of several separate files, spanning 30+ lines each, much of which is just class decorators and and the like. I do feel like often the equivalent program in something like Clojure will be much shorter, and be contained in substantially fewer files without features missing. So much of the stuff that people love about classes, interfaces, and polymorphism can be done pretty easily with replicated with basic first-class maps and multimethods.

Obviously it's not a direct apples-to-apples comparison; Clojure is an untyped language, and performance for it is admittedly generally a little more difficult to predict. But, and obviously this a sample size of one, but I do feel like my programs have less... "fluff" than the equivalent OOP languages.

But if you convert that Java to Kotlin it'll get vastly shorter, whilst still being semantically the same. OOP doesn't have to mean verbosity. Java chose that path to keep the language simple, like how Go is also very verbose but simple (Kotlin is more concise but more complex).
One of the main issues with this is that OO as practiced in C# and Java is only a very thin extract from the real OO as provided by for instance Smalltalk. And without that kind of environment you end up with the worst of both worlds, where you have an OO like interface layered on top of things that aren't really objects to begin with, because they aren't 'alive'.
Very good observation but I do wonder whether it’s the worst of both worlds or the best of both worlds - more the “eat the meat and spit out the bones” approach?
I feel this kind of argument is a bit pedantic though; when people complain about OOP, they're generally complaining about the mainstream implementations of OOP.

I don't think that people people are really considering Smalltalk's OOP style when they complain about Java OOP.

This is far from pedantic. Calling something OOP when it isn't is a huge part of the problem.
One might say that Real OOP has never been tried.
Erlang and Elixir with spawn processes (objects with their own CPU) and send / receive message passing? But they do their best to hide it in OTP behind all that handle_* boilerplate.
There's Smalltalk. And Ruby gets pretty close to Smalltalk, but yeah, in practice it ends up looking like very close to Java. Your point stands.
Chicken and egg, perhaps? OOO lives and breathes state, so complexity (defined as an excess of state consideration) seems a natural pairing, yet the overhead and complexity is increased by each in response to the necessity of the other? That is, when complexity of the problem shifts, there is a parallel increase in the complexity of the OOO solution.

The general anxiety of the movement towards functional or procedural programming in general might also be a feature of age: a young programmer eager to impress that they can juggle 8 balls effortlessly, but called upon to do the same 15 years later might admit 3 balls sufficed to begin with, and is closer to an attainable sustainable solution.

The worst part of OOP is that all the properties of an object can be a mishmash of values and are mutable. In any method, you never know if the object is in some undesirable state without checking properties within the method itself. Multiply that headache across all methods and all other classes and it becomes a mutable mess. It makes it weird that we pass around objects as types when they encapsulate so much state and logic. They aren’t really a concrete data types, they are an entire living village.

With functional languages, it tries to enforce some explicit type signatures in the function arguments so things are cleaner within the functions themselves.

This isn't a property of OOP. This is a property of poor class design. You absolutely should be designing classes such that every possible sequencing of their public methods leaves them in a valid state and maintains their invariants.

Structs have the issue you describe and they aren't really OOP.

Yes, if the first thing you do when you write a class is make a setter method for each field then you will have problems. That's not really a property of OOP.

Poor class design IS a property of OOP.

All of these logical errors that are easy to commit are terrible because they are usually runtime bugs, not compile time.

As I think of it, I think a neat feature of OOP would be conditional methods that are only callable under specific circumstances. For example, the “Customer.SendPasswordResetEmail()” method couldn’t be called (or didn’t even exist) until I verify that the “Customer.IsEmailVerified” property is true.

Being able to add these type of annotations to methods for expected object state would help catch some logic bugs at compile time.

> The worst part of OOP is that all the properties of an object can be a mishmash of values and are mutable.

Const-ness is one of the things I really miss from C++. I could look at an object and be reasonably sure I wasn't mutating it by calling foo.length() for example.

IMHO that is of such little help and the drawbacks weigh much heavier: const-correctness spreads like a cancer (try making one thing const without having to fix a hundred other things), and often requires annoying boilerplate -- I'm no C++ expert but if these are still the best available solutions...: https://stackoverflow.com/a/123995/1073695

Sometimes I want to add one little extra thing that gets mutated in an otherwise "const" method, e.g. for debugging purposes. If the compiler doesn't let me do that because I valued the ideal of const-correctness higher than practical concerns, I know I've done something wrong.

Perhaps it depends on the code-base. I worked on a medium complexity C++ project (~300kLOC) but which used multi-threading quite heavily with shared data structures, and there was only a couple of instances where I felt it got in the way.

In the vast majority of cases it reduced my cognitive load significantly because I could just look at the method declaration and see that my code would be fine.

Yes, as always it all depends on the context and how features are used. Maybe I was just bitten too often in situation where const is particular gnarly to use. I know for sure that in many cases, such as when calling small helper functions for copying a shallow array and such, one can easily pass pointer as pointers-to-const.

However, IME there is a big problem with const for more database-y, more stationary in-memory data. This is the kind of data that is almost always going to be mutated by at least some part of the code at some point in time. There is a fundamental problem of communication between mutating code and non-mutating code (the "strstr()" function, that has to apply a const-cast hack internally to implement its interface, is a trivial example here).

As said there are certainly situations where such "communication" isn't needed, but I'm anxious about precluding the possibility in the name of const-correctness.

I feel that instead of const (or whatever static formalized description of what a function is doing), good naming is most helpful to intuit broadly what that one function was doing again.

In C at least, I've ended up leaving const almost exclusively for the cases where the data is truly const - i.e. in the .ro section of the binary, and I know for sure it won't ever have to be modified, and basically I have to apply the const qualifier lest it puts the data in the wrong section / it needs an awful cast to remove the const. The majority of those are string literals typed as "const char *".

Lisp derived languages, with dynamic gradual typing, and dynamic scopes, say hello.
Erlang and Elixir store state in arguments of recursively called functions, usually running in their own processes separate from the rest of the application. There is nothing in the language to enforce correctness of the state. They are generally regarded as functional languages even if they are somewhat object oriented if one thinks about their message passing as method calls to the object / process storing the state.
>If the only flaw in C# is knowing which method calls requires the new keyword because its a constructor, and which dont because its a factory, that is bad enough to want to avoid it

I'm sure this is just an example popping first out of your mind, but it seems like an oddly specific thing to mention. Specially since the answer is obvious if you know C#: the name of the method matches that of the type if and only if it is a constructor.

I won't comment on the rest of your post as my experience with F# is minimal; but I think I understand where you're coming from.

> Separating Data from Behavior manages complexity better

There's a sweet spot, and it varies. Sometimes it is difficult to find. API design can be difficult. Managing complexity is sometimes itself a complex process.

Every system if allowed to become too complex. No single paradigm of programming is perfect for all cases.

OO is one way to structure and model a system.

No matter what language you use will end up with some form of a struct, a set of values that belong together Then you will have list of some structs and trees of some structs

You will almost certainly have to create list/collections/groupings of structs. Because those are quite useful and universal

How you act on those collections is different between different idioms.

In other words you will create a model of data one way or another and you have to maintain it / change it, as required over time.

The data structures themselves are rather often based on or more database schema where the data will be extracted and saved.

Just like every language is able to be slow/non-performant -- but OO in this case would be Python in a web context; it doesn't invalidate that a good amount of OO codebases in the wild devolve into incomprehensible black boxes, where no one has any idea what anything does or how to make meaningful changes that fulfill the intent of (compare that to iterative programming, where you can atleast read it)

A list: I give you a vector. Plain and simple. Not this insanity: https://referencesource.microsoft.com/#mscorlib/system/colle... [0] You do not need OO to create a vector (or even an array -- god forbid!)

As for trees: roll your own. They're simple enough, yet tightly-coupled with context that no generic implementation exists that is flexible enough. You do not need OO to create a tree. C has been working with trees long before the current Frankensteination of OO was even a twinkle in Gosling's eye.[1]

Data structures do not need inheritance -- they might need delegation (message passing that requires you to actually think about your system).

Data structures do not need encapsulation -- they most likely need namespaces. Realistically, most classes will be used as namespaces.

Data structures do not need polymorphism -- just implement the members you need, and name them appropriately (no 5+ word phrases, please. Please!)

What modern OO does is lower the barrier to productivity in the present, and then pays for it in the future. It's no different than writing your "planet scale" backend system in JS.

[0] Compare to: https://gcc.gnu.org/onlinedocs/gcc-4.6.3/libstdc++/api/a0111...

[1] If you want to know why we have Java: some guys that didn't have the time to think about low-level (memory management specifically) things for their embedded applications, got sick of trying to learn C++, decided to make their own language. That's it. There was no grand plan or thoughtful design -- it's just a mismash of personal preference. The same people that described C++ as "being too complex" (fair) and using "too much memory" (lol)

What do you find insane about the C# `List` source code?

I'm not a C# programmer, but the public API looks sound, and the entire thing is like 1K LOC including docstrings (I guess the inherited code would add to that).

I don't think it's even using inheritance - List implements a few interfaces though?
List is an IList/IReadOnlyList; these interfaces do nothing that couldn't be done right inside the file itself.

https://referencesource.microsoft.com/#mscorlib/system/colle...

https://referencesource.microsoft.com/#mscorlib/system/colle...

Instead we have to go diving through the IList, which implements ICollection, which implements IEnumerable, which implements IEnumerable (again). Just because each interface is composed of another interface, doesn't mean you aren't using inheritance. You are effectively creating a custom inheritance tree through willy-nilly composition.

It is gratuitous to make this chain so deep, when the underlying code is just a handful of lines.

https://referencesource.microsoft.com/#mscorlib/system/colle...

https://referencesource.microsoft.com/#mscorlib/system/colle...

https://referencesource.microsoft.com/#mscorlib/system/colle...

----------------------------------------------------------------------------------------------------------------

The doc-strings are unnecessary. It's self-evident what most of the code does if you read it.

        // Returns an enumerator for this list with the given
        // permission for removal of elements. If modifications made to the list 
        // while an enumeration is in progress, the MoveNext and 
        // GetObject methods of the enumerator will throw an exception.
        //
        public Enumerator GetEnumerator() {
            return new Enumerator(this);
        }

        // Returns the index of the last occurrence of a given value in a range of
        // this list. The list is searched backwards, starting at the end 
        // and ending at the first element in the list. The elements of the list 
        // are compared to the given value using the Object.Equals method.
        // 
        // This method uses the Array.LastIndexOf method to perform the
        // search.
        // 
        public int LastIndexOf(T item)
        {
            Contract.Ensures(Contract.Result<int>() >= -1);
            Contract.Ensures(Contract.Result<int>() < Count);
            if (_size == 0) {  // Special case for empty list
                return -1;
            }
            else {
                return LastIndexOf(item, _size - 1, _size);
            }
        }

        // Returns the index of the first occurrence of a given value in a range of
        // this list. The list is searched forwards, starting at index
        // index and upto count number of elements. The
        // elements of the list are compared to the given value using the
        // Object.Equals method.
        // 
        // This method uses the Array.IndexOf method to perform the
        // search.
        // 
        public int IndexOf(T item, int index, int count) {
            if (index > _size)
                ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.index, ExceptionResource.ArgumentOutOfRange_Index);
 
            if (count <0 || index > _size - count) ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.count, ExceptionResource.ArgumentOutOfRange_Count);
            Contract.Ensures(Contract.Result<int>() >= -1);
            Contract.Ensures(Contract.Result<int>() < Count);
            Contract.EndContractBlock();
 
            return Array.IndexOf(_items, item, index, count);
        }
If you remove these 300 lines of pointless comments, you still have 900 lines of code that is terribly space-inefficient. Everything is "pretty," but slow to read, because of the immense amount of whitespace, nesting, and lines longer than 76 chars. You cannot read long swathes of code in one screenful. You have to scroll vertically and horizontally, because for some reason a standard library needs to throw exceptions (exceptions aren't free; they negatively and noticeably impact performance).

Seriously, you could just use an "out" errno/status. "But then we would have to always check to see if the operation succeeded!": exceptions make people lazy. Just because an exception wasn't thrown, doesn't mean you're doing things correctly.

----------------------------------------------------------------------------------------------------------------

Why does a List implement a search algorithm? Why binary search of all things -- because it's convenient? You know if I need a binary search, I can write one myself. Don't pollute my namespace.

        // Searches a section of the list for a given element using a binary search
        // algorithm. Elements of the list are compared to the search value using
        // the given IComparer interface. If comparer is null, elements of
        // the list are compared to the search value using the IComparable
        // interface, which in that case must be implemented by all elements of the
        // list and the given search value. This method assumes that the given
        // section of the list is already sorted; if this is not the case, the
        // result will be incorrect.
        //
        // The method returns the index of the given value in the list. If the
        // list does not contain the given value, the method returns a negative
        // integer. The bitwise complement operator (~) can be applied to a
        // negative result to produce the index of the first element (if any) that
        // is larger than the given search value. This is also the index at which
        // the search value should be inserted into the list in order for the list
        // to remain sorted.
        // 
        // The method uses the Array.BinarySearch method to perform the
        // search.
        // 
        public int BinarySearch(int index, int count, T item, IComparer<T> comparer) {
            if (index < 0)
                ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.index, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
            if (count < 0)
                ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.count, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
            if (_size - index < count)
                ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_InvalidOffLen);
            Contract.Ensures(Contract.Result<int>() <= index + count);
            Contract.EndContractBlock();
 
            return Array.BinarySearch<T>(_items, index, count, item, comparer);
        }
What if my list -- as is almost always the case -- is unsorted? The result will be incorrect? Looking through the chain of indirection, I cannot see any code checking to see that the list is sorted. Maybe it's there, but it's so much overhead trying to make sense of the List.BinarySearch -> Array.BinarySearch -> ArraySortHelper<T>.Default.BinarySearch -> arraysorthelper.BinarySearch -> arraysorthelper.InternalBinarySearch chain. So I'm going to silently get a wrong result, and the only way to know is to read the docstrings? Thanks.

----------------------------------------------------------------------------------------------------------------

As far as I can tell, it's unoptimized. It's just plain, OO C# meant to be readable. I don't see any tricks or tweaks to get the IL to be more concise/performant. Maybe the compiler is aggressively optimized for the core lib (but I'm not holding my breath -- because I can't see it).

I stopped using C# & F# almost a decade ago, but there are some relevant pieces of information that answer your questions:

1. Optimization is primarily handled by the .Net JIT, not the C# compiler. That allows F#, C#, VB.Net and other runtime languages to share similar performance characteristics without duplicating effort.

2. Docstrings are used by the IDE to help the user. That avoids the need to read the source code itself for regular usage.

3. When comparing the .Net List<> implementation against any C++ std::vector implementation, the former looks quite tame in comparison...

Numbering your citations from zero, ehe? I like the cut of your jib.
I empathize with your characterization of "a tree of object/classes" and I yearn for an example of how else to model a complex, domain-specific system not using the aforementioned tree.
Not the author of the comment, but based on how I understand the comment, I feel essentially the same way.

I would characterize it a bit differently, seeing as, for example (and to your point), a purely functional lisp program is a tree of lambdas and macros. The same could be said of Haskell.

For me the issue is that classes and objects are actually pretty complicated things for what they are. It’s easy to not notice when you’re in the habit of using them, but really pause and think about how complicated they are. They have both structure and machinery that probably aren’t required for most abstractions: regardless, in OOP they get shoehorned into every problem.

This is why OOP ends up with a bunch of well known design patterns, whereas in FP they’re not reaaaaally a thing (arguably).

A tree of functions is probably the simplest possible way to build programs, at a fundamental level: I am not speaking in terms of individual preferences here, but really mathematical simplicity.

well you see! What we can do is to namespace our functions, e.g. by naming them component_create, component_add_button, etc. We then create a plain dictionary with key value pairs that gets passed onto these functions! The functions then possibly return a new map, which is a modified map! This allows us to write code like

  dog = dog_create({name: "foo", age: 12})
  dog = dod_add_friend(dog2)
  print(dog["friends"])
and we can avoid OO completely.

oh... wait a minute

This comment shows a total misunderstanding of what functional programming is…
While tongue in cheek, this is one if the OP in non-OP patterns that is used heavily for large projects in FP alike.
I'm not seeing that in the example, and I'm not even seeing anything very relevant to FP in the example either. I guess there isn't much mutation happening, and functions are called? But that's not what FP is.
This tells me that you never really looked at functional languages, not even used them. The power of ADT, especially when using a comprehensive pattern matching expression, is pretty difficult to emulate in the OOP world without a ton of code. But in this extremely simple case you just need a record.

    let dogBar = {Name = “bar”; Age = 11; Friends = []}
    let dogFoo = {Name = “foo”; Age = 12; Friends = [dogBar]}
    printfn “%A” dogFoo.Friends 
The advantage is that it’s immutable and it’s guaranteed to don’t have null in any fields. C# only introduced records recently, while F# was born with them. And C# still hasn’t got ADT because it’s missing the Union types as far as I remember.
It's not a tree though. A tree doesn't have connected leaves and branches. This is, however, common with classes that might get injected the same dependency
Sounds like missing the tree for the forest. Im not from a pure cs background (so forgive my mangling of terms) but isnt a tree essentially an acyclic graph with constraints, 1 parent 2 children for example? What you're describing is adding some cycles into that graph no?
The number of children can be anything, it's two children for a binary tree. Each node except one node must only have one parent, which isn't true if two or more nodes share one or more children.

And, yes, in theory this adds cycles which aren't allowed. However, since class dependencies are better represented as directed connections (which aren't usually used for trees in CS terms), it isn't a true cycle.

relational model, like we always did and do everyday (in the db realm)

i am not saying we should not use trees ever, i am mainly saying, when the model is a very deep tree (or several deep trees and trees everywhere), its becomes overly complex

data models should be as flat as possible , and only nested when absolutely necessary

Yes, and my yearning was for examples in which the domain objects are complex systems or machines themselves.

To your point, if the domain is a payment system, I can keep separate db's of Customer Info, Customer Purchases, Transaction Instances, Customer payment methods, etc. This seems like a domain suitable for functional code.

If the domain is a two stage orbital rocket, in which we must have a stateful system that has internal feedback loops (fuel consumption, vehicle trim, time of flight, time before stage separation, engine sensor data), our best software design is an object graph which causes spaghetti code ( does the navigation system belong to the electrical system, or the radio system? Wait, does the radio system belong to the electrical system? Wait, does the entire electrical system belong to the solid fuel system, since the electrical system is dependent on the generators partially, but what about the battery system? What critical components stay on the battery system if the generators are shut down?). I guess my point is, real life is a spaghetti relationship.

Consider the recent ISpace probe crash. The article says "software bug" but in reality it's more of a 'design flaw' and I would bet it's exactly because of the topic of this thread. The sensors were reading correct data, but the design/validation of the intercommunication data between sensors was designed wrong.

https://www.nytimes.com/2023/05/26/science/moon-crash-japan-...

Documents are pretty much everywhere. In many cases they are mutable because user needs to edit them, and on the web JavaScript code needs to dynamically modify them.

According to debugging tools in my web browser, your <div class=comment> is at level #15 under the <body> element. I wonder how would you model the in-memory representation of this web page, while keeping the model practical?

The big difference between C# and F# styles (yes you can do either style in both languages, but with varying degrees of friction) is if that tree is mutable or immutable.
It is a mess, become most people don't learn how to code properly.

They would make a mess in Modula-2, or Standard ML as well, given how many need a network layer to write modular code.

F# (and ocaml, on which it was modelled) are oop languages. If you don’t like oop there are functional programming languages that might be better fit for you.
No, Functor, Monad is OOP concept.