Stanislav Arnaudov

Compile-Time Graph

· Stanislav Arnaudov · 3 minute read · 722 words

Abstract

CTGraph is a small C++17 project that implements a graph structure that can be used entirely at compile time. It started as just an experiment. I wanted to see just how much computation I can perform at compile time. I was heavily inspired by part of cpp_box. In there Jason Turner has managed to implement a compile-time finite state machine. I thought that Graphs are not too far off of a finite state machine so I decided to give the idea a try. I spend several days setting up the project and now it is a fully-fledged single header library. I still have not found any practical uses for CTGraph but for now, I am mainly developing it with educational propose. That is, I can discover a ton about C++ by programming smilingly useless things in it.


For more information as well as the source code, check out the GitHub page of the project.

Design goals

The project isn’t exactly serious but I’ve still defined several general guidelines around which I should design everything.

  • Header only – the library mus be usable through the inclusion of a single header
  • C++17 – once the header file of CTGraph is included, the source file must be compiled with C++17.
  • Usable in constexpr context (mostly) – if all of the information is available at compile time, the functions of CTGraph should be able to produce their result also at compile-time. This is not that easy to pull off so I would allow myself a little bit of leniency here.
  • Usable in const context (fully) – once the graph is defined, it should be fully usable through a const qualified reference to the object. This should be possible because the graph can’t be changed once defined so there is no reason for any method of the graph to modify the underlying structure.

Features so far

Basic graph definition

CTGraph requires the vertices of graph to be defines as a enumeration. For example, we can define the possible vertices like:

enum class NodeTypes
{
    UNKNOWN = 0,
    NODE_1 = 1,
    NODE_2 = 2,
    NODE_3 = 3,
    NODE_4 = 4,
    NODE_5 = 5,
    NODE_6 = 6,
    NODE_7 = 7
};

After that the definition of a graph is straight forward:

using namespace ctgraph;
static constexpr auto graph = Graph{Node{NodeTypes::NODE_1, NodeTypes::NODE_2, NodeTypes::NODE_3},
                                    Node{NodeTypes::NODE_2, NodeTypes::NODE_3, NodeTypes::NODE_4},
                                    Node{NodeTypes::NODE_3, NodeTypes::NODE_4},
                                    Node{NodeTypes::NODE_4},
                                    Node{NodeTypes::NODE_5, NodeTypes::NODE_6, NodeTypes::NODE_7}};

A Graph is defined through several Node objects. The semantics of the constructor of Node are Node{<vertex>, [<follower>]}. The first argument is the vertex to be added to the graph and the following vertices are the direct followers of the first one. To be noted is that only the vertices that come as a first argument of the Node constructor are considered “in” the graph. In the example, the will contain NODE_1, NODE_2, NODE_3, NODE_4 and NODE_5 as vertices but not NODE_6 and NODE_7.


The snippet defines the following graph:

Accessing node

You can access the nth vertex through the get_node<n> method

constexpr auto node_0 = graph.get_node<0>(); // NodeTypes::NODE_1

Size of the graph

The number of vertices in the graph:

constexpr auto size = graph.size(); // 5

Checking if vertex is in the graph

The method contains can be used with any of the nodes define in the original enumeration:

constexpr bool is_node_4 = graph.contains(NodeTypes::NODE_4) // true
constexpr bool is_node_7 = graph.contains(NodeTypes::NODE_7) // false

Checking if two vertices are adjacent

Two node – n_1 and n_2 – are adjacent if and only if there is an edge n_1 -> n_2.

constexpr bool edge_2_3 = graph.adjacent(NodeTypes::NODE_2, NodeTypes::NODE_3) //true
constexpr bool edge_1_4 = graph.adjacent(NodeTypes::NODE_1, NodeTypes::NODE_4) //false

Accessing successors of a vertex

Accessing the nth successor of vertex can be done with the successor<n>(node) method:

constexpr auto node_1_succesor_0 = graph.successor<0>(NodeTypes::NODE_1);

Successors can also be accessed through a pointer ptr to the first one as well as their count c. It is guaranteed that the next c elements after ptr will be the successors of the given node:

constexpr auto num_succ = graph.count(NodeTypes::NODE_1); // 2
static constexpr auto ptr_succ = graph.followers(NodeTypes::NODE_1);
// *ptr_succ[0] == NodeTypes::NODE_2
// *ptr_succ[1] == NodeTypes::NODE_3
// *ptr_succ[3] - undefined behavior

There is also a convince function that return both the count and the pointer:

const auto[count, ptr] = get_successors(graph, NodeTypes::NODE_1);
// count == 2
// *ptr_succ[0] == NodeTypes::NODE_2
// *ptr_succ[1] == NodeTypes::NODE_3
// *ptr_succ[3] - undefined behavior