golang

Типы, больше типов

  • четверг, 29 июня 2023 г. в 00:00:16
https://habr.com/ru/articles/744346/

Введение

При описании модели данных, часто приходится создавать новые типы, в первую очередь, используя такие ключевые слова как class/struct/record. Такие типы агрегируют в себе другие типы как простые, так и составные. Все это знают и применяют. Я же предлагаю взглянуть на случаи, когда моделируемая сущность, описывается существующими, часто простыми типами, такими как целое число или строка.

В статье я хочу поделиться мыслями, которые привели меня к использованию специальных типов там, где часто используются встроенные: int, string и тому подобные. На написание статьи побудил релиз и относительно массовый переход на третью версию языка Scala. В частности, я говорю о новой конструкции opaque type, которая упростила создание новых типов поверх других. Также приведу примеры и других на языках с которыми довелось поработать, а именно C++ и Go. Под спойлерами будет как код на соответствующих языках, так и описание к нему.

Почти type alias, но лучше

Один из плюсов использования отдельных типов - повышение читабельности кода, когда видно, что используется не просто какое-то целое число, а, например, временная метка (timestamp) как количество микросекунд прошедших с 1 января 1970 года. Или поля user и goods не просто строковые поля, а ID пользователя и ID товара. С этой задачей вполне справляются псевдонимы типов. Их часто используют, для задания короткого имени, в ограниченной области видимости, для типа с длинным именем, или для предоставления доступа к параметрам шаблона/дженерика.

Примеры:

Scala
// определили типы
type Timestamp = Long
type UserId = String
type SKU = String

// используем
case class Sale(
    customer: UserId,
    item: SKU,
    date: Timestamp,
)
Go
// определили типы
type Timestamp = int64
type UserId = string
type SKU = string

// используем
type Sale struct {
    Customer UserId
    Item     SKU
    Date     Timestamp
}
C++
// определили типы
using Timestamp = int64_t;
using UserId = string;
using SKU = string;

// используем
struct Sale {
    UserId customer;
    SKU item;
    Timestamp date;
};

Удобство чтения - это хорошо, но компилятор, увы, никак не подскажет в случае, если вместо UserId передали SKU и наоборот, или просто строку. Аналогично и там где ожидается Timestamp - можно Timestamp, а можно любое другое целое. Применять можно, применяют, но не в контексте создания самостоятельного нового типа. Хочется, чтобы при передаче значения типа SKU или строкового типа туда, где ожидается UserId компилятор об этом сказал, выдал бы ошибку.

На первый взгляд, такой "оверинжиниринг" не нужен, но на практике я сталкивался с передачей в функции, в качестве параметров, одних и тех же идентификаторов, представленных строками, но в разном порядке. Когда такие функции использовались рядом, то легко можно опечататься и передать параметры не в том порядке. Тесты выловят такое, а может и нет - как повезет. Рефакторить такое, чтобы привести к одному порядку список аргументов во всех функциях тоже не самое приятное занятие сопряженное с ошибками.

Другое место для ошибок - добавление новых аргументов к функции в начало или середину, ну вот по смыслу его логичнее в средину добавить. Например, аргументы являются частями составного ключа и удобнее, если все части ключа располагаются рядом в нужном порядке. В таком случае специализированные типы опять же помогут и в коде вставить их в нужное место.

Scala

Как раз тут и пригодятся opaque type. К объявлению псевдонима типа можно вначале дописать новое ключевое слово opaque. Это создаст новый тип, существовать он будет только во время компиляции, а в рантайме будет из себя представлять просто базовый тип. Таким образом никаких дополнительных затрат на него не будет, как при наследовании от AnyVal, где в определенных случаях обертка создавалась в рантайме.

Из нюансов, создать такой тип можно только из той области видимости где он был объявлен. Если прямо в пакете, то только из него, если внутри объекта, то только в теле или методах этого объекта. В связи с этим, удобно создавать такой тип внутри вспомогательного объекта и добавлять метод apply. Чтобы уменьшить повторения, можно сделать вспомогательный трейт:

trait NewType[T]:
    opaque type Type = T
    def apply(v: T): Type = v

Тогда код из предыдущего примера будет выглядеть следующим образом:

// определили типы
object Timestamp extends NewType[Long]
type Timestamp = Timestamp.Type

object UserId extends NewType[String]
type UserId = UserId.Type

object SKU extends NewType[String]
type SKU = SKU.Type

// используем
case class Sale(
    customer: UserId,
    item: SKU,
    date: Timestamp,
)

И можно убедиться, что передать что-то другое туда где ожидаем новый тип нельзя.

trait NewType[T]:
    opaque type Type = T
    def apply(v: T): Type = v

object Timestamp extends NewType[Long]
type Timestamp = Timestamp.Type

type TimestampAlias = Long

def ExpectAlias(ts: TimestampAlias): String = "OK"

def ExpectNewType(ts: Timestamp): String = "OK"

val t1: TimestampAlias = 1000
val t2: Timestamp = Timestamp(1000)

ExpectAlias(t1) // <- OK
ExpectAlias(1000) // <- OK
ExpectNewType(t2) // <- OK
ExpectNewType(1000) // <- Ошибка компиляции

Использование вспомогательного трейта так же позволит добавить реализации необходимых given instances (реализации имплиситов в терминах новой скалы), которые будут выведены на базе given instances базовых типов.

Go

С Go проще всего. Новый тип создается при помощи type definition.

// определили типы
type Timestamp int64
type UserId string
type SKU string

// используем
type Sale struct {
    Customer UserId
    Item     SKU
    Date     Timestamp
}

Синтаксически отличие от варианта с псевдонимами в том, что при определении типа между именем нового типа и базовым типом нет знака равно. В целом, в Go создание новых типов можно встретить довольно часто, так как это позволяет "добавить" нужные методы к уже существующему типу, чтобы он начал соответствовать требуемому интерфейсу или просто обеспечить более удобную работу с ним.

package main

type Timestamp int64

type TimestampAlias = int64

func ExpectAlias(v TimestampAlias) {}

func ExpectNewType(v Timestamp) {}

func main() {
    v := int64(1000)
    t1 := TimestampAlias(v)
    t2 := Timestamp(v)
    
    ExpectAlias(t1)   // <- OK
    ExpectAlias(v)    // <- OK
    
    ExpectNewType(t2) // <- OK
    ExpectNewType(v)  // <- Ошибка
}

Можно заметить, что во втором случае в ExpectNewType передается не просто число, а переменная с типом int64. Дело в том, что целочисленный литерал неявно приводится к любому типу который может быть сконструирован из него. Это целые числа, числа с плавающей точкой и типы для которых перечисленные являются базовыми (underlying). Поэтому вызов ExpectNewType(1000) не приведет к ошибке.

C++

В C++ подобных языковых конструкций нет. Но можно создать шаблонную структуру с одним полем, где тип поля - шаблонный параметр. А для того, чтобы различать типы, которые базируются на одних и тех же типах, добавить еще один шаблонный параметр - тег. Тегом может являться просто объявленная структура без определения в таком случае.

template <typename T, typename Tag>
struct NewType {
    explicit NewType(T value): value(value) {}
    T value;
};

struct TimestampTag;
using Timestamp = NewType<int64_t, TimestampTag>;

Но на мой взгляд, удобнее, если тег будет представлять собой пустую структуру с типом внутри, который и будет базовым типом. Это даст однозначную связь между тегом и используемым для представления данных типом.

template <typename Tag>
struct NewType {
    explicit NewType(typename Tag::Type value): value(value) {}
    typename Tag::Type value;
};

А использование будет выглядеть так:

struct TimestampTag {
    using Type = int64_t;
};
using Timestamp = NewType<TimestampTag>;

struct UserIdTag {
    using Type = std::string;
};
using UserId = NewType<UserIdTag>;

struct SKUTag {
    using Type = std::string;
};
using SKU = NewType<SKUTag>;

struct Sale {
    UserId customer;
    SKU item;
    Timestamp date;
};

И небольшое сравнение алиаса и типа построенного на базе структуры NewType.

#include <string>

template <typename Tag>
struct NewType {
    using Raw = typename Tag::Type;
    explicit NewType(Raw const& value): value(value) {}
    explicit NewType(Raw && value): value(value) {}
    Raw value;
};

struct TimestampTag {
    using Type = int64_t;
};
using Timestamp = NewType<TimestampTag>;

using TimestampAlias = int64_t;

void ExpectAlias(TimestampAlias) {}

void ExpectNewType(Timestamp) {}

int main() {

    TimestampAlias ts1 = 1000;
    Timestamp ts2 = Timestamp(1000);

    ExpectAlias(ts1); // <- OK
    ExpectAlias(1000); // <- OK
    ExpectNewType(ts2); // <- OK
    ExpectNewType(1000); // <- Ошибка компиляции

    return 0;
}

Использование таких типов, поможет улучшить читаемость кода и уменьшит ошибки вызванные передачей неправильных параметров. В момент написания нового кода, вероятность подобных ошибок не очень большая, но она возрастает при расширении и рефакторинге кода. Хорошо, когда код покрыт тестами и подобные ошибки выловит разработчик до того, как изменения передадут в тестирование. Но ещё лучше, когда такие ошибки не пропустит компилятор.

Так же, введение нового типа позволит переопределить какие-то функции или определить их только для новых типов. Например, способ конвертации в строку для вывода отладочной печати. Так если информация о времени события хранится в Unix Time (число секунд прошедших с 1 января 1970), то смотреть в логи на числа подобные 1682941151 не очень удобно. Гораздо проще понять в какой момент произошло событие, если в логе это значение будет представлено как '2023-05-01T11:39:11Z'. Введение отдельного типа позволит это сделать. Другой пример - добавлять в лог к числу единицы измерения, чтобы было понятно о чем идет речь - градусы, радианы, метры, секунды, граммы или штуки.

Степень проработанности типов зависит от потребностей проекта. Где-то будет удобно переопределить и какие-то математические операторы, ввести функции для манипуляции такими типами, чтобы случайно не перемножить друг на друга килограммы. В большинстве же случаев, достаточно просто будет определить новый тип и в случае каких-то манипуляций извлекать из него базовое значение. А бывает, что и вовсе нет нужды что-то делать кроме как писать/читать в/из хранилища данных или передавать по сети, то есть нужны только функции сериализации и десериализации.

Можно ли лучше?

Всё выше перечисленное уже полезно само по себе. Но можно ли получить от типов еще больше пользы. Да, можно. Когда работаем с моделью данных, может потребоваться ввести ограничение на возможные значения. Например, если описывать треугольник, то ограничением будет, что сумма длин двух прилегающих сторон будет больше третьей. В случае с одним значением подобные ограничения тоже могут быть. Например, географическая широта может быть ограничена значениями от -85° до 85°, а долгота от -180° до 180. Или от пользователя (или внешней системы) ожидается строка только в определенном формате.

Когда данные приходят в систему, то они проходят проверку и упомянутые ограничения, обычно, делаются на этапе валидации входных данных. Но дальше, после валидации, там где эти данные используются, по описанию структуры данных или сигнатуре функции, наличие таких ограничения определить нельзя. Также может возникнуть вопрос, а что делать если в какую-то функцию, которая работает с данными уже прошедшими валидацию, пришли невалидные данные? С одной стороны, такая ситуация должна быть исключена, с другой, при невалидных данных что-нибудь может сломаться или пойти не так, а заметить это может быть не просто.

Какие подходы есть к решению такой проблемы? Самое простое - ничего не делать, считаем, что данные которыми оперируют функции внутри сервиса валидны и ничего не сломается. Можно, вставить проверки, которые в случае ошибки кинут исключение, в языках такие проверки могут называться assert или require. В случае C++ можно встретить, что такие проверки присутствуют в отладочной версии, но исключаются в релизной. Таким образом приложение тестируется с включенными проверками и если что-то пошло не так на тестовом стенде, то приложение упадет, а после тестирования считаем, что подобные проверки не нужны и они убираются. Самый трудоемкий путь - во всех функция добавить проверки и определить поведение в случае, если на вход пришли не валидные данные. Это кропотливый вариант, который сложно поддерживать и в итоге, всё может свестись к однообразным и не информативным ошибкам.

Как альтернатива, можно задать эти ограничения в самих типах. Как и в случае с треугольником, где проверка длин сторон может производиться в конструкторе типа или функции-фабрике. С типами поверх существующих можно сделать так же. Тогда вопрос валидности данных при использовании отпадет сам собой - если значение есть, то оно корректно. Как этого добиться - зависит от языка и в предыдущем разделе была заложена основа для добавления проверок.

Scala

Из упомянутых здесь языков, решение для Scala мне нравится больше всего. Его удобно расширять и использовать, ну и для работы с ошибками в Scala есть уже готовые абстракции. Решение с валидацией мало будет отличаться от создания нового типа без валидации. Так же будет вспомогательный трейт, только он будет содержать еще и шаг валидации, а при создании будет возвращаться не сам тип, а Either. В качестве ошибки, мне нравится использовать NonEmptyList из библиотеки Cats, содержащий строки с описанием ошибок. Его особенность в том, что он всегда содержит как минимум один элемент. Это довольно универсальный вариант, но можно выбрать то, что подходит лучше именно вам.

Базовый трейт будет выглядеть следующим образом:

trait ValidatedNewType[Raw]:
  /** Validation checks whether type can be constructed or not. It returns None
    * if it can be otherwise returns text description of error.
    */
  type Validation = Raw => Option[String]

  opaque type Type = Raw

  private[util] def make(v: Raw): Type = v

  private type ErrorOr[A] = ValidatedNel[String, A]

  def apply(v: Raw): Either[NonEmptyList[String], Type] =
    validations.traverse(f => f(v)).map(_ => make(v)).toEither

  def maybe(v: Raw): Option[Type] = apply(v).toOption

  protected def addValidations(vs: Validation*): Unit =
    validations ++= vs.map { f => (v: Raw) =>
      f(v) match
        case None      => ().validNel
        case Some(err) => err.invalidNel
    }

  private var validations: Vector[Raw => ErrorOr[Unit]] =
    Vector.empty

  extension (t: Type)
    protected def toRaw(): Raw = t

end ValidatedNewType

Трейт добавит публичные методы apply и maybe для создания инстанса типа с проверкой. Проверки добавляются в конструкторе объекта методом addValidations. Метод расширения toRaw, отмеченный как protected, позволяет в наследниках трейта получить доступ к базовому типу, что удобно для добавления различных методов расширения к новому типу.

Использование трейта выглядит так:

trait Degree:
  self: ValidatedNewType[Double] =>
  extension (t: Type) def toRad(): Double = t.toRaw() * Math.PI / 180

object Latitude extends ValidatedNewType[Double] with Degree {
  addValidations(
    v => if v <= -85 then Some("latitude must be greater than or equal to -85") else None,
    v => if v >= 85 then Some("latitude must be less than or equal to 85") else None
  )
}
type Latitude = Latitude.Type

object Longitude extends ValidatedNewType[Double] with Degree {
  addValidations(
    v => if v <= -180 then Some("longitude must be greater than or equal to -180") else None,
    v => if v >= 180 then Some("longitude must be less than or equal to 180") else None
  )
}
type Longitude = Longitude.Type

Здесь еще добавлен трейт Degree, который при подмешивании к объектам Latitude и Longitude добавляет к типам метод расширения toRad, возвращающий значение в радианах. Здесь это просто для демонстрации, а так, можно было сделать публичным метод toRaw.

Добавим класс Point, который будем содержать два поля долготу и широту и функцию для вычисления расстояния между двумя точками:

case class Point(lat: Latitude, lon: Longitude)

def NewPoint(lat: Double, lon: Double): Either[NonEmptyList[String], Point] =
  (Latitude(lat).toValidated, Longitude(lon).toValidated)
    .mapN(Point.apply)
    .toEither

val R = 6371e3

def haversin(x: Double): Double =
  (1 - Math.cos(x)) / 2

def ahaversin(x: Double): Double =
  Math.asin(Math.sqrt(x)) * 2

def distance(p1: Point, p2: Point): Double =
  val lat1 = p1.lat.toRad()
  val lat2 = p2.lat.toRad()

  val lon1 = p1.lon.toRad()
  val lon2 = p2.lon.toRad()

  val d = haversin(lat2 - lat1) + Math.cos(lat1) * Math.cos(lat2) * haversin(
    lon2 - lon1
  )

  val c = ahaversin(d)

  return c * R

Метод toValidated - это метод расширения добавляемый к Either библиотекой Cats. Функция mapN так же из библиотеки Cats, она позволяет несколько значений типа Validated передать в качестве параметров функции, но если хотя бы одно из них не содержит значения, то вернется ошибка. Так же для типов ошибки должен существовать метод их комбинации, в терминах Cats, для них должен быть объявлен тайп-класс Semigroup.

В итоге, расчет расстояния между двумя точками будет выглядеть так:

val d1 = for {
  p1 <- NewPoint(40.123, -73.456)
  p2 <- NewPoint(-30.456, 60.123)
} yield distance(p1, p2) / 1000.0

println(d1) // Right(15718.027575967817)

val d2 = for {
  p1 <- NewPoint(140.123, -273.456)
  p2 <- NewPoint(-130.456, 260.123)
} yield distance(p1, p2) / 1000.0

println(d2) // Left(NonEmptyList(latitude must be less than or equal to 85, longitude must be greater than or equal to -180))
Go

Тут компилятор Go мало, что может предложить. Если хочется, чтобы при создании типа была обязательная валидация, то остается только написать в документации, чтобы тип создавался с использованием определенной функции и надеяться, что этому будут следовать.

Как вариант, можно сделать приватные типы и в функциях принимать их, но тогда использование таких типов будет ограничено одним пакетом. И всё равно остается некоторая возможность подставить неправильное значение, например, передав константу из которой может быть сконструирован базовый тип. Но в целом, это будет работать, так как в основном валидации требуют данные приходящие из вне, которые будут представлены как переменные и автоматического приведения типов не будет.

В качестве примера, определим пакет geo:

type Metres float64
type Kilometres float64

type latitude float64
type longitude float64

type point struct {
    Lat latitude
    Lon longitude
}

type Point = *point

func (p *point) String() string {
    return fmt.Sprintf("%v, %v", p.Lat, p.Lon)
}

func (m Metres) ToKilometres() Kilometres {
    return Kilometres(m / 1000)
}

func NewLat(v float64) (latitude, error) {
    if v <= -85 || v >= 85 {
        return 0, fmt.Errorf("latitude must be between -85 and 85, but got %v", v)
    }
    return latitude(v), nil
}

func NewLon(v float64) (longitude, error) {
    if v <= -180 || v >= 180 {
        return 0, fmt.Errorf("longitude must be between -180 and 180, but got %v", v)
    }
    return longitude(v), nil
}

func NewPoint(lat latitude, lon longitude) Point {
    return &point{
        Lat: lat,
        Lon: lon,
    }
}

// Distance returns the shortes distance, in metres, between two geo points.
func Distance(p1, p2 point) Metres {
    // compute distance ...
}

И для примера, будем получать координаты от пользователя и считать дистанцию между точками.

func readPoint() (geo.Point, error) {
    var (
        rawLat float64
        rawLon float64
    )

    if _, err := fmt.Scanf("%f, %f", &rawLat, &rawLon); err != nil {
        return nil, err
    }

    errs := make([]error, 0, 2)

    lat, err := geo.NewLat(rawLat)
    if err != nil {
        errs = append(errs, err)
    }

    lon, err := geo.NewLon(rawLon)
    if err != nil {
        errs = append(errs, err)
    }

    if len(errs) != 0 {
        return nil, errors.Join(errs...)
    }

    return geo.NewPoint(lat, lon), nil
}

func main() {
    fmt.Print("Input start point (lat, lon): ")
    p1, err := readPoint()
    if err != nil {
        log.Fatalf("[E] reading the start point failed: %v", err)
        return
    }

    fmt.Print("Input end point (lat, lon): ")
    p2, err := readPoint()
    if err != nil {
        log.Fatalf("[E] reading the end point failed: %v", err)
        return
    }

    d := geo.Distance(*p1, *p2)

    fmt.Printf("Distance between points %v and %v is %.2f km\n", p1, p2, d.ToKilometres())
}

При таком подходе использовать типы geo.latitude, geo.longitude и geo.point вне пакета geo нельзя. В качестве обходного пути, можно задать экспортируемый псевдоним на указатель не экспортируемого типа, как сделано для geo.point. Псевдоним на указатель позволяет ссылаться на тип из внешнего кода, а так же использовать методы добавленные к типу *geo.point. Если бы тип geo.Point был бы задан как отдельный тип, а не псевдоним, то метод String был бы не доступен.

Почему нельзя экспортировать непосредственно geo.point? В данном случае, можно, так как значение по умолчанию для типа float64 (ноль) для geo.latitude и geo.longitude являются валидными и созданный объект вызовом geo.point{} будет так же валидным. Но если бы это было не так, то можно было бы создать невалидный объект, а этого хочется избежать.

Если хочется из вне использовать типы geo.latitude и geo.longitude, то можно сделать аналогичные псевдонимы на указатели на них.

Примеры исполнения программы:

Input start point (lat, lon): 40.123, -73.456
Input end point (lat, lon): -30.456, 60.123
Distance between points 40.123, -73.456 and -30.456, 60.123 is 15718.03 km
Input start point (lat, lon): 140.123, 270.456
[E] reading the start point failed: latitude must be between -85 and 85, but got 140.123
longitude must be between -180 and 180, but got 270.456

Если оперировать указателями на такие объекты, то в качестве значения можно передать nil, но в целом проверка на nil может быть дешевле, чем проверка, что переданные объект отвечает нужным требованиям, к тому же, если не делать никаких проверок - переданный указатель на nil проще отловить - это с большей вероятностью приведет к падению не же ли к испорченным данным, что заметить и исправить может оказаться или очень сложно, а то и просто не возможно.

C++

Будем развивать идею, заложенную в первой части. Так же заведем шаблонную структуру которая будет представлять основу для новых типов. Первое с чем надо определиться, так это как создавать её. Использование конструктора мне не нравится, так как в случае передачи неправильных значений в конструктор, всё, что можно сделать - это бросить исключение. Поэтому предлагаю пойти иным путем и добавить статический метод New, который будет возвращать или созданное значение, или ошибку.

Для того чтобы объединить возвращаемое значение и ошибку воспользуюсь типом std::expected из C++23. Компилятор g++ (у меня 12.2.0) уже содержит этот тип в составе стандартной библиотеке (с флагом -std=C++2b). Так как проверок может быть много и хочется вернуть результат всех проверок, то в качестве возвращаемой ошибки буду использовать такую структуру:

struct MultipleErrors {
  std::string first;
  std::vector<std::string> rest;
};

В итоге определение типа ValidatedNewType, которое будет служить базой для создания новых типов будет выглядеть так:

template <typename T>
using Result = std::expected<T, MultipleErrors>;

template <typename T>
using Validation = std::function<std::optional<std::string>(const T &)>;

template <typename Tag>
struct ValidatedNewType {
  using Type = ValidatedNewType<Tag>;
  using Raw = typename Tag::Type;
  using Result = ::Result<Type>;

  static Result New(const Raw &);

  friend std::ostream &operator<<(std::ostream &os, const Type &v) {
    return os << v.value_;
  }

private:
  explicit ValidatedNewType(const Raw &value) : value_(value) {}
  Raw value_;
};

template <typename Tag>
typename ValidatedNewType<Tag>::Result
ValidatedNewType<Tag>::New(typename ValidatedNewType<Tag>::Raw const &value) {
  std::vector<std::string> errs;
  for (Validation validation : Tag::validations) {
    if (auto err = validation(value); err) {
      errs.emplace_back(*err);
    }
  }

  if (errs.empty()) {
    return Result(Type(value));
  }

  return std::unexpected(
      MultipleErrors{errs.front(), std::vector(++errs.begin(), errs.end())});
}

Статический метод New ожидает, что тип Tag содержит статический набор проверок. Проверки - это функции с сигнатурой:

std::optional<std::string> (const Tag::Type&);

Если проверка не удалась, то возвращается std::optional со строкой описывающий ошибку, в противном случае возвращается пустой std::optional.

Для удобства, так же добавлен operator << для вывода значений в консоль.

Определим типы для долготы и широты, используя ValidatedNewType:

struct LatitudeTag {
  using Type = double;
  static const std::vector<Validation<Type>> validations;
};

const std::vector<Validation<LatitudeTag::Type>> LatitudeTag::validations = {
    [](double v) -> std::optional<std::string> {
      return v <= -85 ? std::optional<std::string>(
                            "longitude must be greater than or equal to -85")
                      : std::optional<std::string>();
    },
    [](double v) -> std::optional<std::string> {
      return v >= 85 ? std::optional<std::string>(
                           "longitude must be less than or equal to 85")
                     : std::optional<std::string>();
    }};

using Latitude = ValidatedNewType<LatitudeTag>;

struct LongitudeTag {
  using Type = double;
  static const std::vector<Validation<Type>> validations;
};

const std::vector<Validation<LongitudeTag::Type>> LongitudeTag::validations = {
    [](double v) -> std::optional<std::string> {
      return v <= -180 ? std::optional<std::string>(
                             "longitude must be greater than or equal to -180")
                       : std::optional<std::string>();
    },
    [](double v) -> std::optional<std::string> {
      return v >= 180 ? std::optional<std::string>(
                            "longitude must be less than or equal to 180")
                      : std::optional<std::string>();
    }};

using Longitude = ValidatedNewType<LongitudeTag>;

Использовать их можно так:

struct Point {
  Latitude lat;
  Longitude lon;
};

std::ostream &operator<<(std::ostream &os, const Point &v) {
  return os << "Point(" << v.lat << ", " << v.lon << ")";
}

Point NewPoint(Latitude lat, Longitude lon) {
  return Point{lat, lon};
}

int main() {
  auto lat1 = Latitude::New(60);
  auto lon1 = Longitude::New(40);
  auto p1 = mapn(NewPoint, lat1, lon1);

  std::cout << "lat1: " << lat1 << "\n";
  std::cout << "lon1: " << lon1 << "\n";
  std::cout << p1 << "\n";

  std::cout << "\n";

  auto lat2 = Latitude::New(-89);
  auto lon2 = Longitude::New(-181);
  auto p2 = mapn(NewPoint, lat2, lon2);

  std::cout << "lat2: " << lat2 << "\n";
  std::cout << "lon2: " << lon2 << "\n";
  std::cout << p2 << "\n";

  return 0;
}

Вывод программы будет таким:

lat1: expected(60)
lon1: expected(40)
expected(Point(60, 40))

lat2: unexpected(longitude must be greater than or equal to -85)
lon2: unexpected(longitude must be greater than or equal to -180)
unexpected(longitude must be greater than or equal to -85; longitude must be greater than or equal to -180)

Дополнительно был объявлен operator << для типа std::expected, а также, для комбинации нескольких std::expected, добавлена функция mapn, упрощенно, её сигнатура:

template <typename R, typename Error, typename T1, typename T2>
std::expected<R, E> mapn(std::function<R(T1, T2)> &&f, std::expected<T1, E> const &v1, std::expected<T2, E> const & v2);

Она принимает функцию от аргументов которые "хранятся" в передаваемых std::expected. Если все переданные std::expected содержат значения, то они извлекаются и подставляются в переданную функцию. Результат оборачивается в std::expected и возвращается. Если хотя бы одно из переданных значений содержит ошибку, то возвращается ошибка. Полную реализацию можно найти в репозитории.

Когда использовать такой подход? Везде, где хочется. Есть ограничения на возможные значения, или простой тип имеет какое-то специальное смысловое значение в моделируемой области - это поводы задуматься о создании нового типа. При организации обмена данных можно встретить подходы с использованием XSD-схем или JSON-схем, для описания передаваемых данных. Часто, они содержат ограничения, и вот эти ограничения тоже можно выразить в типах. Да, это потребует некоторой дополнительной работы, но больший пласт делается единожды - определить базовый тип, добавить функции, который будут выполнять сериализацию/десериализацию основываясь на сериализации/десериалиазции нижележащих типов.

Указанные варианты реализации можно расширить, чтобы не только проводить валидацию, но и выполнять какие-то манипуляции. Примером таких манипуляций может быть экранирование символов в строке, для подстановки её в SQL запрос, хотя сейчас, это и нужно довольно редко, так как есть готовые библиотеки для построения запросов, которые делают это.

Полный код примеров можно найти в репозитории.