Skip to content

Shapes and extents

A tensor's shape says how many elements it has along each axis. In strata a shape is a type, Shape<...>, and each axis is either static, its extent written into the type, or dynamic, its extent known only at run time and marked Dyn.

import strata;
import std;

using strata::Dyn;
using strata::Matrix;
using strata::Shape;

Why a type

Putting an extent in the type lets the compiler know it. A static extent costs no memory, can be checked at compile time, and lets small tensors live inside the object with no allocation. A dynamic extent costs one std::size_t and is checked when the program runs. Mixing the two is the point: Shape<3, Dyn> is a matrix with 3 rows and however many columns you decide later.

Shape Rank Dynamic axes Size
Shape<3, 4> 2 0 12, known at compile time
Shape<3, Dyn> 2 1 3 times the second extent
Shape<Dyn, Dyn> 2 2 the product of both, at run time
Shape<> 0 0 1: a scalar has exactly one element

Making a shape

A constructor takes exactly the dynamic extents, in axis order, as integers of any type other than bool. The static ones are already in the type.

constexpr Shape<3, 4> fixed{};                 // nothing to give
const Shape<3, Dyn, 5> mixed{4};               // (1)!
const Shape<Dyn, Dyn> dynamic{rows, columns};  // one per dynamic axis
  1. The middle axis is the only dynamic one, so {4} is its extent: the shape is (3, 4, 5).

Giving the wrong number of extents does not compile: Shape<3, Dyn, Dyn>{4} and Shape<3, 4>{4} are both errors.

A default-constructed shape has every dynamic extent equal to 0, as std::extents does.

When you have every extent in hand, static ones included, use from_extents. The static ones must agree with the type:

const auto shape = Shape<3, Dyn>::from_extents({3, 7});   // same as Shape<3, Dyn>{7}

Asking a shape

constexpr auto shape = Shape<3, Dyn, 5>{4};

static_assert(shape.rank() == 3);
static_assert(shape.dyn_rank() == 1);   // dynamic axes
static_assert(shape.extent(1) == 4);    // static or dynamic, the number
static_assert(shape.size() == 60);      // 3 * 4 * 5

static_assert(Shape<3, Dyn>::static_extent(0) == 3);   // what the type fixes, if anything
static_assert(!Shape<3, Dyn>::static_extent(1).has_value());
static_assert(Shape<3, Dyn>::is_dynamic(1));

rank, dyn_rank, static_extent and is_dynamic are static: they are facts about the type, and need no object. extent and size read the shape itself. All of them work in constant expressions.

An extent of 0 is allowed and gives a size of 0: Shape<3, 0>{}.size() == 0.

What is checked

Invalid extents are caught as early as they can be.

A static extent must be a non-negative integer, not bool, and below the maximum of std::size_t, which strata keeps as the marker of a dynamic extent. The product of all static extents must also fit std::size_t. Violating any of these fails to compile.

A dynamic extent must be non-negative, and the size of the whole shape must not overflow std::size_t. from_extents additionally requires the static extents to match the type.

extent(axis) and static_extent(axis) need axis < rank().

Violations are not exceptions

A failed check is a precondition violation. In constant evaluation it is a compile error. At run time it prints the message and the call site, then aborts, and only in builds without NDEBUG. Do not rely on catching it, and do not rely on it in release builds.

Converting between shapes

A shape converts to one that fixes less: a static extent may become dynamic, never the other way round.

Shape<Dyn, Dyn> a = Shape<3, 4>{};      // fine: both become dynamic
Shape<Dyn, 4>   b = Shape<3, 4>{};      // fine: the second stays static
// Shape<3, 4>  c = Shape<Dyn, 4>{};    // error: a dynamic extent may not become static
// Shape<4, Dyn> d = Shape<3, 4>{};     // error: 3 and 4 are not the same

The same rule governs tensors and views, because they hold a shape. A view over a Shape<3, 4> tensor converts to a view over Shape<Dyn, Dyn>, so a function can take the general one and accept both.

Shapes of expressions

An expression such as a + b needs a shape for its result. strata takes the most specific one both operands allow: static wherever either operand fixes the extent.

const Matrix<float, 2, 3> a = /* ... */;
const Matrix<float, Dyn, Dyn> b{Shape<Dyn, Dyn>{2, 3}, 0.5F};

Matrix c = a + b;   // Tensor<float, Shape<2, 3>>: the static shape wins

Operands whose shapes can never agree, because their ranks differ or two static extents on the same axis differ, do not compile. When they agree on static extents but a dynamic one hides the difference, the mismatch is found at run time.

Printing a shape

A shape formats with std::format:

Spec Output of Shape<3, Dyn, 5>{4} Meaning
{} (3, 4, 5) the extents, as NumPy prints a shape
{:?} Shape<3, Dyn, 5>(3, 4, 5) the type's static extents first, Dyn where dynamic

Any other spec is a compile error.

Under the hood

How a shape stores its extents

Each template argument is turned into an extent: a number becomes a static one, Dyn a dynamic one. They are two distinct types rather than a sentinel value, so a dynamic axis reads as such in compiler diagnostics and debuggers instead of as a huge number.

Only the dynamic extents occupy memory. Shape<3, 4> is an empty class, and Shape<3, Dyn, Dyn> is the size of two std::size_t. Internally the static values and the marker for "stored" live in one list, which is what SemiStaticValues is: std::extents without its interface. Strides of a layout are made of the same thing.

See also