C++ For C# Developers: Part 53 – Conclusion
Today we conclude the series by reflecting on how C++ and C#, as well as their standard libraries, compare. We’ll also think a little about how their differences change the way we write code.
Table of Contents
- Part 1: Introduction
- Part 2: Primitive Types and Literals
- Part 3: Variables and Initialization
- Part 4: Functions
- Part 5: Build Model
- Part 6: Control Flow
- Part 7: Pointers, Arrays, and Strings
- Part 8: References
- Part 9: Enumerations
- Part 10: Struct Basics
- Part 11: Struct Functions
- Part 12: Constructors and Destructors
- Part 13: Initialization
- Part 14: Inheritance
- Part 15: Struct and Class Permissions
- Part 16: Struct and Class Wrap-up
- Part 17: Namespaces
- Part 18: Exceptions
- Part 19: Dynamic Allocation
- Part 20: Implicit Type Conversion
- Part 21: Casting and RTTI
- Part 22: Lambdas
- Part 23: Compile-Time Programming
- Part 24: Preprocessor
- Part 25: Intro to Templates
- Part 26: Template Parameters
- Part 27: Template Deduction and Specialization
- Part 28: Variadic Templates
- Part 29: Template Constraints
- Part 30: Type Aliases
- Part 31: Destructuring and Attributes
- Part 32: Thread-Local Storage and Volatile
- Part 33: Alignment, Assembly, and Language Linkage
- Part 34: Fold Expressions and Elaborated Type Specifiers
- Part 35: Modules, The New Build Model
- Part 36: Coroutines
- Part 37: Missing Language Features
- Part 38: C Standard Library
- Part 39: Language Support Library
- Part 40: Utilities Library
- Part 41: System Integration Library
- Part 42: Numbers Library
- Part 43: Threading Library
- Part 44: Strings Library
- Part 45: Array Containers Library
- Part 46: Other Containers Library
- Part 47: Containers Library Wrapup
- Part 48: Algorithms Library
- Part 49: Ranges and Parallel Algorithms
- Part 50: I/O Library
- Part 51: Missing Library Features
- Part 52: Idioms and Best Practices
- Part 53: Conclusion
Language
C++ and C# have quite different design goals. C++ aims to be able to be implemented by a compiler so efficiently that a programmer would never need to use another language, like C, to improve performance. In practice, assembly is sometimes used when ultimate performance is required. It’s debatable as to whether this counts as another language. C++ then tries to provide as much programmer convenience as it can while also keeping to a high degree of backward-compatibility.
The goal of C# is different. It attempts to provide a lot more programmer convenience than C++ and is willing to sacrifice performance to achieve that. From the perspective of languages like Java, Python, and JavaScript, C# is much closer to the performance end of the spectrum. C# finds a middle ground. Its inclusion of structs is just one example of C#’s willingness to increase the complexity programmers need to deal with so that they can improve performance. Java is simpler because it just has classes so there’s only one kind of thing that groups together variables and functions, not two.
Because C# doesn’t aim for extreme performance, C# programmers aiming to achieve extreme performance often do resort to calls into other languages. Chief among them are C++ and C. This bifurcated experience itself increases the complexity of the programming environment as marshaling between the languages is required and few concepts, such as types, are shared.
Likewise, many C# features can’t be used when high performance is required. Classes and arrays, for example, necessarily entail memory management and garbage collection (GC) which are nigh impossible to optimize for high performance use cases such as VR games. Even Unity’s Burst compiler is forced to put a ban on language features like these. Many Unity developers have long ago banned or minimized their use as well. The resulting programming experience, replete with cumbersome and error-prone requirements such as object pools, is far from ideal.
The same kind of criticisms are made of C++, but in the opposite direction. It’s focus on performance results in many sharp edges. Variables aren’t initialized by default and it’s pretty easy to use a “dangling” pointer. There’s a lot of “undefined behavior,” too. Most of this is necessary because providing these guarantees is deemed to be too limiting or would entail overhead such as the addition of a GC.
In the end, both languages have different goals and have made decades of design choices in line with achieving those goals. Each language becomes rather unpleasant to use outside its intended purpose. C++ is a probably a poor choice for a web service and C# is probably a poor choice for training a neural network. Heroic efforts have been made to improve C++’s programmer-friendliness and C#’s performance, but these remain uphill battles even after many years of struggle.
Standard Library
C++’s standard library is much more conservative than C#’s. It’s company- and industry-agnostic and sticks to well-standardized techniques and algorithms. C#’s standard library is has a lot of company-specific features, especially when it comes to Microsoft-owned technologies such as Windows. In general, it’s a lot larger than the C++ Standard Library as it contains all of this company-specific functionality but also a lot of support for widely-used standards such as JSON and AES. One consequence of this broader support is that support for older features such as GDI+ are carried forward as baggage in C# or dropped at the cost of backward-compatibility.
In terms of design, the two again diverge quite a bit. C++ provides powerful language features that enable it to efficiently implement “core” types like strings and tuples in the C++ Standard Library. C# prefers to build these into the language. Where C++ provides zero-overhead extension, such as through template-based compile-time polymorphism, of the types in its standard library, C# often provides little extensibility or extensibility via mechanisms such as virtual functions that entail a runtime cost. The C# standard library is typically easier to use and more consistent across codebases but with lower performance and customizability. This is an extension and implication of the two languages’ design goals to their standard libraries.
Users of either standard library ultimately turn to other libraries and frameworks to complete their apps. Whether it’s Unity for a game or ASP.NET for a web service, C# apps rarely rely solely on the standard library. The same goes for C++ where its users build games on Unreal Engine or computer vision on OpenCV. Both languages are very popular so there are tons of libraries available for a wide range of tasks.
Problems Writing C#
The choice of language brings with it all the choices made by the creators of that language and its standard library. In choosing C#, we’re choosing a language where many of the features require the presence of a memory manager and a GC. Consider classes. There’s no way to allocate the memory where they’re stored without the memory manager and no way to explicitly deallocate that memory. The new
operator implicitly tells the memory manager to allocate memory, its use is implicitly tracked, and it’s implicitly deallocated for us when no longer in use. It’s not just hard or awkward to take control over the lifecycle of a C# class, it’s impossible.
When we need this level of control, we’re outside the C#’s comfort zone and we’ll face headwinds. To illustrate, let’s consider two paths we could take to solve the problem. First, we can use a subset of C# that doesn’t include features like classes. This is the route taken by Unity’s Burst compiler and its “High Performance C#” (HPC#) language subset. It uses structs and (unsafe) pointers instead of classes in order to provide its own memory allocation and deallocation.
The main issue with this approach is that a lot of C# language and library design assumes that classes are present. When we kick them out of the language, we lose our only mechanism that supports inheritance, virtual functions, default constructors, and reference semantics. We also make almost all C# libraries unusable as they don’t conform to our language subset. The result is a very constrained environment where we end up needing to call Dispose
functions to manually manage memory and where we cut ourselves on sharp edges like the use of uninitialized objects due to the lack of default constructors or the use of objects after calling Dispose
. Runtime safeguards can and have been added, but with runtime overhead and feedback on programming errors delayed to runtime. Neither is necessary in idiomatic C# where classes are used.
The second path is to keep using the whole language but in a very unidiomatic way. This has been the traditional approach to C# programming in Unity. One common example is the object pool where we avoid releasing references so that the GC doesn’t run and cause a frame spike:
public class ParticlePool { private Stack<Particle> Particles; public Particle Get(Color color) { if (Particles.Count > 0) { Particle p = Particles.Pop(); p.Init(color); // Need an Init function in addition to a constructor return p; } return new Particle(color); } public void Release(Particle p) { Particles.Push(p); } } // At startup, establish the pool ParticlePool pool = new ParticlePool(); // When needed, get a particle Particle p = pool.Get(Color.Red); // ... use p ... // When done, put it back in the pool pool.Release(p); // Nothing stopping us from using particles we released. Causes conflicts! p.Color = Color.Green;
Manual approaches like these, without any support from the language, are notoriously error-prone. Usually it’s a complaint against C++ that memory must be managed manually, but it turns out to be necessary in either of C#’s high-performance paths too. Either way, we’re outside of the design goals for C# and so we run into a lot of resistance in terms of performance and ease-of-use barriers.
Problems Writing C++
C++ is no panacea. Its problems tend to be the other way around: more code has to be built up to make the language programmer-friendly because the defaults are often downright dangerous. C++ is outside its own comfort zone by default and almost always requires library support to make it usable in any practical sense. Consider the same problem of memory management. C++’s default for the new
and delete
operators is quite error-prone:
// When needed, allocate and initialize a particle Particle* p = new Particle(Color::Red); // ... use p ... // When done, delete it delete p; // Nothing stopping us from double-deleting. Causes crashes! delete p; // Nothing stopping us from using particles we deleted. Causes crashes! p->Color = Color::Green;
It’s to the point that best practices discourage using these language features outside of specialty code such as classes that own the memory through their lifecycle functions. We need to instead use library code that makes the raw language easier to use:
// Need a library #include <memory> void Foo() { // When needed, allocate and initialize a particle std::unique_ptr<Particle> p = std::make_unique<Particle>(Color::Red); // ... use p ... } // unique_ptr's destructor deletes the Particle
The addition of this library code brings us to roughly the level of convenience as in idiomatic C#, but layers of libraries have overhead in terms of complexity, compile times, and verbosity. Because we opt-in to this library code, it’s also easy to accidentally ignore it and use raw language features. Libraries can’t save us from these mistakes. It’s common to add static and dynamic analyzer tools, but none are as robust as language-level safeguards.
This is a reflection of C++’s bottom-up design. The language is extremely powerful but also extremely hard to use. The C++ Standard Library is layered on top to make it easier to use, but only for very general tasks. Additional libraries are then layered on top of these to make domain-specific tasks easier. C++ achieves great flexibility and great performance because libraries can be ignored but the language cannot. C# builds a lot into the language and thus has much less flexibility as so much is unavailable for us to opt-out of. On the other hand, this makes C# code a lot more standardized. For example, we never see an alternative implementation of the string
type but C++ has many: std::string, FString, QString, fbstring, CsString, CString, …
Conclusion
There is no best language, even within the domain of game programming. C# is the language of Unity, but that choice is a mixed bag of problems and benefits. C++ is the language of nearly every other game engine, but that too has many problems and benefits.
Practically, our best option is to learn the strong and weak suits of the two languages and use them for the purposes they’re best suited to. A deep knowledge of each language, their standard libraries, and the surrounding world of libraries and frameworks is extremely helpful when it comes to knowing what’s possible, what’s feasible, which language to choose for which task, and, ultimately, how to go about the process of actually implementing in the chosen language.
Hopefully this series has delivered on its goal of broadening your skills so you can effectively write code for other engines, or even write C++ scripts for Unity!
#1 by Liam on May 24th, 2021 ·
Great work. But working around GC and performance pitfalls feels nice and familiar – like ActionScript :)
#2 by Stephen Hodgson on May 24th, 2021 ·
I think it’s a bit unfair that the the unsafe keywords weren’t addressed to work with unmanaged resources in c# https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/unmanaged-types
#3 by Mike on May 24th, 2021 ·
“This is the route taken by Unity’s Burst compiler and its “High Performance C#†(HPC#) language subset. It uses structs and (unsafe) pointers instead of classes in order to provide its own memory allocation and deallocation.â€
#4 by Cuku on November 18th, 2021 ·
Thank you so much for the effort put into bringing this series together.
Clear, practical and very good overview!
#5 by M. S. Farzan on May 15th, 2023 ·
Thank you so much for this wonderful series! It’s an incredible resource!