In this course, I am going to encourage you to write "stupid code" rather than "clever code" or "enterprise-class" code.
Stupid Code is code that you will be able to understand and work with three years from now when you are scrambling to fix a bug; even at 3am before a submission deadline; and even on a borrowed laptop.
Stupid code tends to follow a few basic principles:
assert()
' is more valuable than a comment.Locality is your most powerful tool for writing stupid code. When you need to understand code, it is very useful to have all of the code you need to understand in one place, clearly arranged.
Thankfully, C++ has some great features that can help you achieve locality.
The most powerful feature for working with locality in C++ is scoping.
Whenever you see { int x; ... }
you know that whatever is within the brackets is going to be a unit of functionality *and* that whatever the variable x
is doing, it isn't going to be used outside of that chunk.
Also, if you use the RAII ("resource allocation is initialization") paradigm -- and I encourage you to do so -- scopes show when certain resources are active.
{ //draw badges
UseProgram use(ui_tex_prog);
BindTexture bind0(GL_TEXTURE0, badges_tex);
GLuint attrib_Position = ui_tex_prog("Position");
GLuint attrib_TexCoord = ui_tex_prog("TexCoord");
badge_verts.VertexAttribPointer(attrib_Position);
badge_texcoords->VertexAttribPointer(attrib_TexCoord);
EnableVertexAttribArray enable_position(attrib_Position);
EnableVertexAttribArray enable_texcoord(attrib_TexCoord);
ui_tex_prog["scale"] = BigRad;
ui_tex_prog["tint_color"] = tint;
ui_tex_prog["translate"] = glm::vec2(col_mid, badge_y);
glDrawArrays(GL_TRIANGLE_STRIP, 0, badge_verts.count);
}
Get in the habit of throwing a comment in the first line of a scope to make it clear what's going on.
Let me call out RAII once more. Very useful for making it so you never forget to write clean-up code.
struct GLTexture {
GLTexture() { glGenTextures(1, &tex); }
~GLTexture() { glDeleteTextures(1, &tex); tex = 0; }
GLuint tex = 0;
}
Lambdas (local functions) in C++ give you a great way to encapsulate and re-use local scopes without breaking the flow of the code.
Quick lambda refresher:
auto func = [/*capture*/](/*params*/){
/*body*/
};
auto
because the compiler gets to know what type it is. Think of it as an opaque function pointer that also contains a reference-counted block of captured parameters. Even though the type of a lambda can't be written explicitly, you can use std::function<>
to hold them.
Example: you just finished writing this nice interpolation function:
void Title::update(float elapsed) {
t += elapsed; //accumulate time
//fade in the main title:
if (t < 0.5f) {
main_color.a = 0.0f;
} else if (t < 1.5f) {
main_color.a = (t - 0.5f) / (1.5f - 0.5f);
} else {
main_color.a = 1.0f;
}
}
And now you want to add a subtitle:
void Title::update(float elapsed) {
t += elapsed; //accumulate time
auto fade = [&t](float t0, float t1, float *alpha) {
if (t < t0) {
*alpha = 0.0f;
} else if (t < t1) {
*alpha = (t - t0) / (t1 - t0);
} else {
*alpha = 1.0f;
}
};
//fade in titles:
fade(0.5f, 1.5f, &main_color.a);
fade(0.8f, 1.8f, &sub_color.a);
}
Example: say you want to sort some data by a particular key:
struct Player {
glm::vec2 position;
float score;
};
std::vector< Player > players;
//sort players by score:
std::sort( players.begin(), players.end(), [](Player const &a, Player const &b) {
return a.score < b.score;
});
Two different features that go together well.
Group together related variables in a clean way (also great when debugging):
struct {
float speed = 1.0f;
std::string title = "racer-x";
} config;
Great for temporary transformations of data:
struct Range {
Range(uint32_t index_, int32_t min_, int32_t max_) : index(index_), min(min_), max(max_) { }
uint32_t index;
int32_t min, max;
bool marked = false;
};
std::vector< Range > ranges;
for (auto const &sprite : sprites) {
ranges.emplace_back(&sprite - &sprites[0], sprite.x - sprite.r, sprite.x + sprite.r);
}
//...
In many cases -- especially when working with OpenGL -- you want a resource that is initialized once, but putting it at the global scope is wrong for a number of reasons.
The static
keyword helps with this.
void draw() {
//really bad (leaks buffer):
GLuint buffer = 0;
if (buffer == 0) {
/*initialize buffer*/
}
//fine (but not a great habit, for subtle reasons):
static GLuint buffer = 0;
if (buffer == 0) {
/*initialize buffer*/
}
//superb:
static GLuint buffer = []() -> GLuint {
GLuint buffer = 0;
/*initialize buffer*/
return buffer;
}();
//...
}
Note that static variables used in this way tend to work against failing early (see below), so there is a balance to be struck here!
Paranoia -- in the coding sense -- means stating what you know clearly.
C++ gives you some great ways to do this: types, assert
, and static_assert
.
Variable types are an easy way to help understand a program.
Seeing Image i;
vs int i;
is going to be very informative.
Basic tips for type sanity:
for (uint32_t i = container.size() - 1; i < container.size(); --i) {
//even for reverse loops! Really!
}
uint32_t
) are compact and convenient, especially when doing I/O. It makes it clear what size and signedness of integer you've got.
(Note that glm provides nice sized vector types as well.)
const int Count = 15; //Make it clear that Count shouldn't change
void shuffle_copy(const std::vector< int > &from, vector< int > *to) {
//it's pretty clear that 'from' shouldn't be changed
//... though keep in mind that there is nothing preventing "from" and "to" from aliasing
}
const
, but it does provide a *guarantee*, which can be helpful.
void Game::update(float elapsed) {
//make sure these trig functions aren't recomputed every frame:
constexpr float DirX = std::cos(M_PI * 0.25f);
constexpr float DirY = std::sin(M_PI * 0.25f);
meteor.pos += elapsed * glm::vec2(DirX, DirY);
}
//Good use of auto:
auto iter = container.begin();
//without auto:
std::vector< std::pair< int, float > >::iterator iter = container.begin();
//Bad use of auto:
auto total = player.hp + player.shield;
//without auto:
float total = player.hp + player.shield;
std::unique_ptr
says "mine, and mine only", while std::shared_ptr
says "here be complex reasoning")
void step_particles(std::vector< Particle > const &in, std::vector< Particle > *out_) {
assert(out_ && "output array is required.");
auto &out = *out_; //(note: debatable use of auto)
/* ... */
}
typedef
. It can obscure important information.Never Write An Assert Than Should Fail
i.e. "don't use assert() to check data errors, use it to check programming errors"
(For data errors, I recommend throwing std::runtime_error
or -- if you are feeling oldschool -- exit(1)
.)
//bad assert: [bad data file can cause assert()]
std::vector< glm::vec3 > normals = load_normals_from_file();
for (auto const &n : normals) {
assert(n != glm::vec3(0.0f, 0.0f, 0.0f) && "normals shouldn't be zero-length");
}
//good assert: [loading routine establishes invariant; assert() checks it later]
std::vector< glm::vec3 > normals = load_normals_from_file();
for (auto const &n : normals) {
if (n == glm::vec3(0.0f, 0.0f, 0.0f)) {
std::cerr << "ERROR loading file: found a zero-length normal." << std::endl;
return;
}
}
/* ... code that preserves non-zero normals goes here */
for (auto const &n : normals) {
assert(n != glm::vec3(0.0f, 0.0f, 0.0f) && "normals can't be zero-length or file loading wouldn't have succeeded");
}
Note that this is a fuzzy line. You will need to make judgement calls as to whether, e.g., errors in the contents of data files written by your code are covered by assert()
.
You probably won't actually care in these fuzzy cases; so just pick an option.
Consider making them more helpful by including a && "description of problem"
in the parameter.
This will get printed when the assert fails, which can jump-start your debugging process.
At the very least, place a comment near the assert justifying why it will never fail.
assert(pos[i].x <= pos[i+1].x && "pos array is always sorted in x-coordinate.");
//or:
assert(pos[i].x <= pos[i+1].x); //pos array is always sorted in x-coordinate.
NDEBUG
isn't being defined by a command-line flag. (see MSDN.)asserts()
.
If you do ever decide to not compile them the missing side effects will confuse you.
#ifdef PARANOIA
or similar, toggle as needed.A special assert that checks at compile time, rather than runtime. Very useful when, e.g., making sure that structures are packed.
struct Color {
uint8_t r,g,b,a;
};
static_assert(sizeof(Color) == 4, "Color is packed as expected.");
With all this talk of runtime errors, it's worth thinking for a moment about *when* you want runtime errors to trigger. That is, ideally, as soon as possible.
This comes up especially with resource loading. Consider a missing texture in the final cutscene of the game. You want to find out about this ASAP rather than after playing through the entire experience.
void init() {
intro_texture = load_texture("intro_texture.png");
level_texture = load_texture("level_texture.png");
final_texture = load_texture("final_texture.png");
}
void draw_final_scene() {
draw_texture(final_texture);
}
The Load template in our base code (2022 version linked here) can help with this:
Load< Mesh > mesh(LoadTagDefault, []() -> const Mesh * {
return new Mesh(data_path("meshes/main.mesh"));
});
Of course, this breaks when you start having more assets than you have memory to work with; in which case you may still wish to have some notion of a streaming asset handle and a special mode that makes sure that all handles connect to real assets.
Do only what needs doing.
Corollary: always write code for an immediate reason.
Corollary: don't optimize until you've profiled.
Corollary: do things the easy way first.
Corollary: prefer to be wrong than to be right in a cumbersome way (i.e., avoid unnecessary generality).
Pick a compiler for each platform. I recommend:
Compiling with three different tools should -- one hopes -- give one three different sets of static checks, ensuring better code quality.
Pick a way to call that compiler on each platform. You'll want something explicit in its build steps and easy to remember how to run:
The standard library in C++ is really, really good. It will save you a lot of work.
Things you should make sure you are aware of (and should use as needed):
vector
, deque
, and list
.
And the associative containers map
, set
, unordered_map
, and unordered_set
.
Make sure you have a mental model of these containers (i.e., their complexity, iterator invalidation rules, and likely implementation.)algorithm
mix nicely with templated containers. sort
(note: stable_sort
) and reverse
will do a heck of a lot for you. Also, I guess, push_heap
and pop_heap
.
chrono
for time-related functions, including high-precision and high-performance clocks.random
for std::mt19937
-- for deterministic pseudo-random number generation. [Caveat: avoid distributions -- they are not, last I checked, specified to provide consistent results across platforms.]thread
and atomic
are both really convenient.Use #pragma once
in headers instead of #ifdef
wrapping.
Check out if constexpr
. Great for making compile-time decisions.
Understand the cleverness of if (T *t = weak_ptr_to_t.lock())
.
Stick to a coding style. I prefer:
lowercase_with_underscores
for local variables and parameters.CapitalCase
for class/struct names.ALL_CAPS
for macros.Remember that using tab characters for indentation reaffirms the intrinsic goodness and diversity of humanity. So just do it.