STD::COUT << “HELLO WORLD” << “\n”
It has been 7 years since I started working as a Unity developer. Like many other developers, in the evenings after work I create various pet projects and rarely bring them to release. One day I got tired of making game prototypes and decided to switch to something else. That’s how I came to wanting to learn C++. There were two main reasons:
- Almost any serious software, including game engines, is written in pure C or C++. I was very curious to understand how they actually work. It’s also useful to know what is happening “under the hood” of your games and engines.
- Learning a new programming language is always beneficial. You start looking at programming from a different angle, and knowing multiple languages gives you an advantage on the competitive job market.
How did I start learning C++? I tried reading books, but kept running into the same issue — lots of water and information I already knew. As a result, I got bored and abandoned reading. Then I decided to go the route of writing simple console applications: from the first “Hello World!” to shortest path algorithms. Whenever I wanted to implement something I knew from C# in C++, I just searched the internet for how that “thing” works in C++ and how to use it.
At some point writing console applications became boring and I wanted to work with UI and graphics. So I started learning OpenGL and tried to write my first graphics engine. However, I’m not a graphics programmer, and that’s why I eventually dropped OpenGL. Later I discovered the wonderful library Raylib, written entirely in C by a single person. I liked it because it already abstracted OpenGL away from me, provides basics for file handling and primitive graphics, and has a large collection of examples.
As of January 2026, I am writing my own game engine with an editor based on Raylib — Bread Engine. During this time I have gained quite a solid understanding of core C++, which is why this article is a compilation of basic C++ knowledge with a strong focus on people who already know C#. Feel free to come back to this article repeatedly if you forget something or don’t understand a concept. So, let’s get started.
Warning!
This article is not a guide on “How to write cool C++ code”. It is rather an overview of language features and selected parts of the standard library (std). It also assumes that you already have some experience writing programs in C#.

Build systems and code compilation
Let’s talk about something you rarely think about when working on .NET projects. How does your project actually get built into an .exe, .dll or other artifacts? Let’s go top-down:
- The root of your project is a .sln or .slnx file. It contains the list of all .csproj files, paths to external folders, and environment variables needed for the build.
- Inside the solution file you can have one or more .csproj files. Inside them you store .cs files, files with other extensions, and metadata describing dependencies on other .csproj projects or NuGet packages. This is the file where you specify whether this .csproj should be built as .exe, .dll, or something else.
- Each .cs file contains direct dependencies on other namespaces, local and global variables, and instructions for interacting with them.
What happens when you run dotnet build? MSBuild is launched (it is guaranteed to be installed on your machine). It reads the .sln/.slnx files, collects dependency information, and generates XML files describing the project. Using these XML files it builds a dependency graph to correctly determine what depends on what. All code is transpiled into assembly functions, resources are copied to the output folder, and finally the desired artifacts are created. At this point your project is considered successfully built.
Unfortunately (or fortunately), in the C++ world there is no single universal build tool. Every project has to configure everything manually, which provides flexibility but also creates problems out of nowhere.
Let’s briefly look at what an average C++ project consists of:
- Source code base — files with extensions .h/.hpp and .c/.cpp
- Third-party libraries — also .h/.hpp and .c/.cpp files
- Resources: text files, images, sounds, etc.
- At least one build configuration file
Let’s focus on the last point. While most parameters and references in .NET are written automatically into .sln and .csproj files, in C++ you have to describe every single thing (files, folders) yourself. Here are the most popular build systems:
- CMake — the most popular build system, used in ~80% of projects. Has built-in package support (similar to NuGet), works on all platforms. Drawbacks: slow build speed and not the most convenient API.
- Ninja — one of the newest systems, focused on maximum build speed. Has an even less convenient API than CMake, but CMake can generate Ninja files. This also solves compatibility issues with other CMake projects.
- Xmake — essentially CMake but on Lua. Also has built-in dependency support and cross-platform capabilities. However, it is very unpopular and the documentation is only partially written.
- MSBuild — native integration with Visual Studio, but suitable only for Windows and still usually requires CMake.
In any of them you will have to: specify C++ standard version, path to the compiler, links to other libraries, formats and paths to all source code that should be included, output path for build artifacts and resource copying. This is the bare minimum. Each system has many additional features that can help with building your project.
Fields, links and pointers
Fields and references in C++ work similarly to C#, with a couple of exceptions. Let’s write a piece of C# code and break down how things work.
C#
...
ClassA a = new ClassA(); // operator 'new' creates class variable on heap
StructB b = new StructB(); // operator 'new' creates struct variable on stack
DoSomeJob(a, b); // a is passed by reference, b is copied
DoSomeJobByRef(a, b); // a and b are passed by reference
// a is destroyed by garbage collector
// b is popped from stack
}
void DoSomeJob(ClassA a, StructB b)
{
...
}
void DoSomeJobByRef(ClassA a, ref StructB b)
{
...
As we can see, the new operator itself decides how to allocate objects in memory depending on whether the entity is a class or a struct. Now let’s look at the equivalent situation in C++.
C++
...
ClassA *a = new ClassA(); // operator 'new' creates class variable on heap
StructB *b = new StructB(); // operator 'new' creates struct variable also on heap!
DoSomeJob(a, b); // a and b are passed by value (pointer copy — 8 bytes)
DoSomeJobByRef(a, b); // a and b are passed by pointer (original address)
// Mandatory cleanup
delete a;
delete b; // Required for all variables created via 'new'
}
void DoSomeJob(ClassA *a, StructB *b)
{
...
}
void DoSomeJobByRef(ClassA *a, StructB *b) // * = pointer to memory cell. Can be nullptr
{
...
And another example:
C++
...
ClassA a;
StructB b; // both variables created on stack
DoSomeJob(a, b); // both variables are copied
DoSomeJobByRef(a, b); // both variables passed by reference
// no need to call delete — variables are popped from stack automatically
}
void DoSomeJob(ClassA a, StructB b)
{
...
}
void DoSomeJobByRef(ClassA &a, StructB &b) // & = reference, similar to 'ref'
{
...
In C++ it does not matter whether the entity is a class or a struct at the moment of object creation. The programmer decides where the variable is created — on the stack or on the heap. However, when using the heap and new, you must not forget to delete the variable with delete, otherwise you get memory leaks and fragmentation.
Smart pointers
As you may have already realized, C++ has no automatic garbage collector, which places memory management responsibility on the developer. But forgetting to delete an instance created with new is not as dangerous as deleting it twice.
Here is the most common scenario. A programmer creates ClassA* a = new ClassA();. Now *a points to a memory cell, say at address 0xC0597. Then this *a is passed to another class. Of course, at the end we call delete a;. But unexpectedly — we didn’t know that the other class also calls delete on the pointer it received. As a result, the first delete frees the memory, and now *a points to invalid memory (dangling pointer) or gets set to nullptr. The second delete attempts to delete invalid memory or nullptr, which leads to undefined behavior. Usually the application crashes.
To avoid forcing the programmer to remember where delete should be called, starting from C++11 the language introduced smart pointers: std::unique_ptr<T> and std::shared_ptr<T>. Their purpose is to eliminate manual usage of new and delete. Smart pointers automatically destroy the owned instance as soon as they go out of scope. The difference is that unique_ptr owns the instance exclusively — as soon as the pointer leaves scope, the instance is destroyed. shared_ptr keeps a reference count and allows multiple owners. The instance is destroyed only when the reference count reaches zero.
C++
...
std::unique_ptr<Cat> cat = std::make_unique<Cat>(); // make_unique replaces 'new'
cat->sleep(); // call method
} // 'cat' is automatically deleted here
OR!
...
std::unique_ptr<Cat> cat = std::make_unique<Cat>();
cat->sleep(std::move(cat)); // transfer ownership to sleep method
} // here 'cat' is nullptr, but the object still lives inside sleep
C++
...
std::shared_ptr<Service> service = std::make_shared<Service>();
service->Process();
AnotherClass::WorkWithService(service); // reference count increases
} // service pointer destroyed here, but Service instance lives on in AnotherClass
Collections
In general, std:: containers in C++ are very similar to .NET collections. All of them are dynamic except array. Let’s look at them in a table:
| Collection type | Access to element | Add or Remove element |
|---|---|---|
| array<T> (fixed size) | Any | No, only override |
| vector<T> | Any | Yes |
| deque<T> | Any | From start or end |
| list<T> | Sequentially in both directions | From start or end |
| forward_list<T> | Sequentially in one direction | From start or end |
| stack<T> | Last added element | Only from top |
| queue<T> | From start | Add to end, remove from start |
| set<T> / unordered_set<T> (only unique values) | Sequentially / Any | Yes |
| map<T,M> / unordered_map<T,M> (analog of Dictionary<T,M>) | Any | Yes |
If we need to pass a collection as a parameter but don’t want to write separate methods for every possible collection type (similar to IEnumerator<T> in C#), we can use std::span<T>:
C++
...
std::vector<int> nums1{1, 2, 3, 4, 5};
std::cout << max(nums1) << std::endl;
int nums2[]{4, 5, 6, 7, 8};
std::cout << max(nums2) << std::endl;
}
int max(std::span<int> &data) // works with any contiguous range
{
int result = data[0];
for (auto value : data)
{
if (result < value) result = value;
}
return result;
}
Macros
Another useful code generation tool is macros. A macros is a text substitution mechanism that can take parameters. Macros can replace static constants, for example:
C++
#ifndef PI_VALUE // guard to prevent redefinition
#define PI_VALUE 3.141592653589793f
#endif
...
auto circle_size = 27 * PI_VALUE;
They can also generate larger code blocks:
C++
#define COMPONENT_TO_STRING(COMP) \
auto fields = COMP.getFields(); \
std::string comp_string = ""; \
for (auto field : fields) \
{ \
comp_string += field.getName() + " : " + field.getValueString(); \
comp_string += "; "; \
} \
std::cout << "Component data: " << comp_string << "\n";
...
std::unique_ptr<Component> CreateComponent()
{
auto component = std::make_unique<Component>();
COMPONENT_TO_STRING(component);
return component;
}
Macros are often used to generate utility methods for new types (TO_JSON, REFLECT, SERIALIZE, etc.). However, keep in mind that macro-generated code is very hard to debug, and because macros are not type-safe, parameter-related issues can appear easily.
Error handling
Error handling in C++ works similarly to C#: we still have try/catch and can create custom exception types. The only missing part is the finally block.
C++
...
ServiceProvider serviceProvider;
try
{
Service* service = provider.getService<Network>();
service->init();
...
}
catch (std::runtime_error& ex) // catch by reference to avoid copy
{
... ex.what() ... // what() returns the exception message + stack trace in some implementations
}
catch (std::exception& ex) // catch-all for standard exceptions
{
// handling
}
You can also create your own exception types:
C++
class MyTestException : public std::exception
{
std::string what_message;
public:
MyTestException(const std::string& msg) : what_message(msg) {}
const char* what() const noexcept override
{
return what_message.c_str();
}
};
...
catch (MyTestException& ex)
{
...
}
Templates and code generation
Sometimes we need code that works with multiple data types without duplicating it. In C# we use generics for this. Example:
C#
interface IAnimal
{
string GetName();
}
class Cat : IAnimal
{
private string name = "cat";
public string GetName() => name;
}
class Dog : IAnimal
{
private string name = "dog";
public string GetName() => name;
}
class AnimalFactory<T> where T : class, IAnimal, new()
{
T CreateAnimal()
{
T instance = new();
Console.WriteLine($"{instance.GetName()} is created");
return instance;
}
}
...
IAnimal animalOne = new AnimalFactory<Cat>().CreateAnimal();
IAnimal animalTwo = new AnimalFactory<Dog>().CreateAnimal();
Generics help us avoid code duplication and enable abstraction, making code more flexible. At compile time the compiler generates non-generic code for each concrete T used. So in the compiled output we effectively have separate AnimalFactory<Cat> and AnimalFactory<Dog>.
C++ has an equivalent mechanism called templates. Let’s rewrite the example in C++:
class IAnimal {
public:
virtual std::string GetName() = 0; // pure virtual = must be overridden
};
class Cat : public IAnimal {
private:
std::string name = "cat";
public:
Cat() = default;
std::string GetName() override { return name; }
};
class Dog : public IAnimal {
private:
std::string name = "dog";
public:
Dog() = default;
std::string GetName() override { return name; }
};
template <typename T>
requires std::derived_from<T, IAnimal>
&& std::default_initializable<T>
class AnimalFactory {
public:
std::unique_ptr<IAnimal> CreateAnimal() {
auto instance = std::make_unique<T>();
std::cout << instance->GetName() << " is created\n";
return instance;
}
IAnimal* CreateSpecific() {
auto instance = new T();
std::cout << instance->GetName() << " is created\n";
return instance;
}
};
...
AnimalFactory<Cat> catFactory;
AnimalFactory<Dog> dogFactory;
auto animalOne = catFactory.CreateAnimal();
IAnimal* animalTwo = dogFactory.CreateSpecific();
Any template starts with the keyword template <typename …>. It can be applied to a class, struct, or individual functions/methods. After template we can specify constraints on T (inheritance, default constructibility, etc.). At compile time the compiler finds all usages of each concrete T and generates specialized code for them. Keep in mind that heavy usage of templates/generics increases binary size.
Templates also allow full or partial specialization — changing behavior for specific types:
template <typename T>
class A
{
void print(T object)
{
std::cout << std::to_string(object) << " printed\n";
}
};
template<>
class A<std::string>
{
void print(std::string text)
{
std::cout << text << " printed\n";
}
int v = 1;
void someMethod() {}
};
Const correctness
Last but not least, one of the important C++ features is the const keyword. Do not confuse it with C# const. In C# const means immutable for the entire application lifetime. In C++ const means “cannot be modified in this particular context” — it is much closer to C# readonly.
C++
const int a = 4;
a = 5; // compile error
...
const int a = 4;
change(a); // compile error
void change(int& a)
{
a = 5;
}
Const can also serve as a promise from a function that it will not modify certain parameters:
C++
int a = 1;
change(a, 2);
a = 5; // OK — a is not const in this scope
void change(const int a, int b)
{
a = 5; // compile error
b = 6; // OK
}
It can also be applied to member functions to promise that the method does not modify non-const members or call non-const methods:
C++
int some_global_variable = 5;
class Example
{
public:
void some_non_const_method() { ... }
void const_method() const { ... }
void change(int a) const // const after the parameter list
{
a = 7; // OK — local parameter
some_global_variable = 6; // compile error — modifies global state
some_non_const_method(); // compile error — calls non-const method
const_method(); // OK
}
};
So const lets developers create clear “contracts” between methods and avoid unexpected data modification in surprising places.
Interesting moments
This section collects other language features worth mentioning, but not big enough for separate sections.
Friend class:
C++ has no internal access modifier, so when your application consists of multiple modules and you need controlled communication between them without making everything public, the friend keyword helps. friend allows one class/struct to access private and protected members of another.
C++
class A
{
void call_init()
{
B b_class;
b_class.init(); // OK — private method accessible because A is friend of B
}
};
class B
{
private:
friend class A; // A is now a friend of B
void init();
};
Interfaces
C++ has no interface keyword, but inheritance is more flexible. When inheriting, you can choose up to which access level the base class members are inherited. Access order for classes: private → protected → public. For structs the order is reversed (public → protected → private). If no access specifier is given, the first one in the order is used by default.
class Base
{
public:
void init();
protected:
int calculate() const;
private:
void to_string();
};
class A : Base {}; // inherits only to_string() as private
class B : protected Base {}; // inherits to_string() and calculate() as protected
class C : public Base {}; // inherits everything publicly
In fact, any .h (header) file in C++ is an interface. In it, you declare fields, methods, and access levels to them. That is why you always inherit the header file.
std::variant
A small but useful feature: when you need to store or pass values of several possible types, use std::variant<T, T1, …>:
std::string text = "some text";
int a = 5;
bool isActive = true;
to_string(text);
to_string(a);
to_string(isActive);
void to_string(std::variant<std::string, int, bool> variant)
{
if (std::holds_alternative<std::string>(variant))
{
std::string text = std::get<std::string>(variant); // or std::get<0>(variant)
// or even: std::string text = variant;
}
if (std::holds_alternative<int>(variant))
{
...
}
}
Conclusion
C++ is actually a very large language, and describing every useful feature in one article would make it unreasonably long. I would have liked to cover multithreading and asynchronous programming, text/files processing, using directives, or at least std::any. If the language interests you, you will eventually encounter these topics yourself.
As you can see, C++ is very similar to C# both in syntax and in functionality. Using only the concepts described in this article, you can already write full-fledged applications without major problems. Start with small console applications and gradually move to more complex tasks to learn the features step by step.
Thanks for reading to the end!
