Skip to content

Latest commit

 

History

History

utils

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 

ovis::utils

This module contains general C++ utilities. The following sections contain the most important aspects of the module.

Type ID

The header native_type_id.hpp contains the type NativeTypeId (just a typedef for a 32 bit unsigned integer) as well as the variable template constexpr NativeTypeId TypeOf<T> which calculates a compile-time id for a given type.

Versioned Index ID

The header versioned_index.hpp contains the class VersionedIndex which can be used for resources that are stored in arrays. In general, the index of the element already represents a unique ID for resources stored in an array. However, if the element slots are reused, the same ID now points to a different element and it is impossible to detect this. VersionedIndex stores a 'version' number in addition to the actual index that is intended to be increased whenever the slot is reused. This way, you can detect whether the id belongs to the resources at the corresponding index.

using MyId = VersionIndex<uint32_t, 8 /* VERSION_BITS */, 24 /* INDEX_BITS */, 0 /* FLAGS_BITS */>;

In this example MyId is essentially a 32bit unsigned integer with 8 bits reserved for the version and 24 bits for the actual index. In addition you can reserve additional bits for flags. In general, you only have to specify the type and the number of version bits. The remaining bits will be used for the index by default, so VersionIndex<uint32_t, 8> would yield the same type.

Ranges

The header range.hpp contains a lot of helper functions regarding ranges. Most of the concepts have made its way into the C++ 20 standard in the new ranges library and they should be used instead.

IntegralRange

Generate an integer range, similar to std::ranges::iota_view. Usage:

for (auto i : IntegralRange<int>{0, 3}) {
  // i will have the values 0, 1, 2
}
// or:
for (auto i : IRange(3)) {
  // in contrast to std::views::iota(), calling IRange with a single argument
  // it denotes the end of the range that starts at zero, so this also iterates
  // over the values 0, 1, 2
}

IndexedRange

Equivalent of std::views::enumerate.

std::vector<std::string> strings = { "foo", "bar" };
for (auto string : IndexRange(strings)) {
  // string.value() or string-> accesses the underlying value
  // string.index() returns the zero-based index
}

RangeFilter

Equivalent of std::views::filter.

const auto numbers = { 0, 1, 2, 3, 4, };
for (auto number : FilterRange(numbers, [](int number) { return number % 2 == 0; })) {
  // number will have the values 0, 2, 4
}

RangeAdapter

Equivalent of std::views::transform.

const auto numbers = { 0, 1, 2, 3, 4, };
for (auto number : TransformRange(numbers, [](int number) { return number * 2; })) {
  // number will have the values 0, 2, 4, 6, 8
}

TupleElementRange

Equivalent of std::views::elements.

const std::map<std::string, int> dict = { {"foo", 2}, {"bar", 4} };
for (auto key : Keys(dict)) {
  // "foo", "bar"
}
for (auto value : Values(dict)) {
  // 2, 4
}

Error Handling

The header result.hpp contains a the Result<T, E> class which is used within the engine for error handling instead of error codes or exceptions. Result<T, E> works similar to the proposed std::expected<T, E> or Result<T, E> in Rust. In addition, the header defines a simple Error which simply stores an error message. This error type is sufficient for most use cases where you do not want to differentiate between different errors that can occur during a function call. Error has a constructor that takes a format string and its corresponding parameters to produce the final message using {fmt} (look below for an example).

Usage

Result<T, E> is intended to be used as a return type for functions. T should be the type the function should return on success (using void for functions that do not produce a value is completely fine). E should be the error type, which defaults to the simple Error struct described above.

E.g., a function that reads the whole content of a text file could look like this:

Result<std::string> ReadFile(const std::filesystem::path& path) {
  std::ifstream file(path);

  if (!file) {
    return Error("Cannot open file: {}", path);
  }

  file.seekg(0, std::ios::end);
  std::string content(file.tellg(), '\0');
  file.seekg(0, std::ios::beg);
  if (!file.read(content.data(), content.size())) {
    return Error("Could not read file: {}", path);
  }

  return std::move(content);
}

Then you would could use the function like this:

if (auto content = ReadFile(some_path); content.has_value()) {
  // Instead of content.has_value(), you could simply write content, as the bool operator is overloaded
  
  // Result<T, E> behaves a little bit like std::optional or a smart pointer in the sense that the arrow
  // operator (content->) as well as dereferencing the result will give access to the underlying T.
  print("The content of the file is: {}", *content);
} else {
  print("Oh no, an error occured: {}", content.error().message);
}

Usage of void Results

The Result class is specialized for Result<void, E>, which basically results in and std::optional<E>. Result<void, E> does not have a has_value() method, but the bool operator is still overloaded to return true if the function succeeded and false if an error occured. Similarly, it does not have the arrow and dereference operator overloaded, as this would not make sense in this context. Constructing a successful Result<void, E> is also strange, as there is no T we can construct it with. First, a default constructed Result<void, E> would indicate success, however, this was confusing so the SuccessType struct was introduced. Similarly, to std::nullopt_t its only use is to construct a Result<void, E> that indicates success. For convenience, the global declaration constexpr SuccessType Success; exists to allow simple construction of such types. E.g.,:

Result<> WriteFile(const std::filesystem::path& path, std::string_view content) {
  std::ofstream file(path);

  if (!file) {
    return Error("Cannot open file: {}", path);
  }

  if (!file.write(content.data(), content.size())) {
    return Error("Could not write file: {}", path);
  }

  return Success;
}

Error propagation

When you want to propagate errors through a function call you can do it like this:

Result<Int> ReadIntegerFromFile(const std::filesystem::path& path) {
  const auto content = ReadFile(path);
  if (!content) {
    return content.error();
  }
  return std::stoi(*content); // Please use std::from_chars instead of stoi in real code!
}

However, the header also defined the OVIS_CHECK_RESULT macro which essentially does the same this as the if. So, you can write this instead:

Result<Int> ReadIntegerFromFile(const std::filesystem::path& path) {
  const auto content = ReadFile(path);
  OVIS_CHECK_RESULT(content);
  return std::stoi(*content); // Again, use std::from_chars instead
}

Unfortunately, there is now way to implement a macro with similar functionality to the ? operator in Rust.

Safety

In debug mode, the Result class also checks whether the user perfomed a check if the result has a value before accessing it. So, this would trigger an assertion:

std::string value = *ReadFile(some_filename); // Triggers an assertion in debug mode, even if the result contains a value

This is, to ensure that potential errors are not simply ignored. It is not completely safe, as the following would pass, but it is better than nothing.

auto content = ReadFile(some_filename);
bool has_value = content.has_value(); // The return value of has_value() needs to be used as it is flagged with [[nodiscard]]
content->size(); // This would pass in debug mode, and would lead to undefined behaviour when content actually contains an error.
// I chose not to check for a value on every dereferencation due to runtime overhead (same as std::optional, ...).

Caveats

Currently there are two constructors of Result<T, E>: one which takes an T and one that takes an E. This will lead to a compile-time error if T == E or if the constructor gets called with a type that is neither T or E but implicitly convertible to both. However, these use cases are unlikely to occur in normal usage that I decided to not support this instead of wrapping the error like std::unexpected.