Введение

При описании модели данных, часто приходится создавать новые типы, в первую очередь, используя такие ключевые слова как 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 запрос, хотя сейчас, это и нужно довольно редко, так как есть готовые библиотеки для построения запросов, которые делают это.

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

Комментарии (44)


  1. ChessMax
    28.06.2023 08:32
    +2

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


  1. ChessMax
    28.06.2023 08:32
    +2

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


    1. Hokum Автор
      28.06.2023 08:32

      Вот это совпадение! Я рад встретить единомышленников. :)


    1. Tuxman
      28.06.2023 08:32
      -2

      Dart? Язык немного взлетает только благодаря Flutter.
      Отвечу сюда, потому что для C++ такое нагромождение шаблонной магии ещё как-то можно оправдать, но Dart, как замена JS, там порог вхождения в отрасль чуть ниже, так что вы их только напугаете.


  1. Kelbon
    28.06.2023 08:32
    -1

    Подобное использование "типов" абсолютно бесполезно и ухудшает код и производительность

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

    То, что написано в последней трети это вообще, простите(нет), мусор. using Result = expected и Type::New неоднозначно намекают откуда копируется мусор.

    Просто напомню, что в С++ существуют конструкторы и ещё миллиард способов для создания адекватной инкапсуляции


    1. Hokum Автор
      28.06.2023 08:32
      +1

      Производительность в случае со Scala не уменьшается. В отношении Go, если использовать просто type definition, то не должно влиять. А в C++, если делать подобную обертку, оптимизируют код. Без такой оптимизации, тот же std::unque_ptr был бы крайне неэффективным. Если говорить о простых типах типа int, то разницы совсем не будет. В случае с более сложными, как std::string, да, появляются некоторые накладные расходы. Можно посмотреть результат компиляции: https://godbolt.org/z/7o8bP91jz

      Что до ухудшения кода - это скорее понятие вкуса. Кто-то считает излишеством, кому-то нравится. Мне и команде на одном из проектов подобное понравилось и мы активно использовали. В других командах к таким предложения относились скептически.

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


      1. Kelbon
        28.06.2023 08:32
        -1

        1. производительность в скале просто по умолчанию неочень

        2. в С++ компилятор конечно постарается, но во первых вы добавляете лишние конвертации, во вторых запрещаете много где RVO, про то что вы из примитивных типов делаете не примитивные и во что это выльется для регистров и прочего промолчу

        3. Этот код просто напросто не несёт никакой смысловой нагрузки, он бесполезен

        4. Если у типа ДЕЙСТВИТЕЛЬНО есть что проверять (инварианты), то он делает это через private и конструкторы + методы

        5. первые две трети статьи заменяются на именованные аргументы функций


          https://godbolt.org/z/h61663bsn

        struct foo_named_args {
            int v1;
            float v2;
        };
        void foo(foo_named_args) {}
        
        int main() {
            foo({.v1 = 5, .v2 = 3.14});
        }


        1. Hokum Автор
          28.06.2023 08:32
          +2

          Правильно именованные аргументы, не всегда спасают, так как при совпадении типов компилятору всё равно какое имя. Так же может сработать неявное приведение, которое не всегда желательно.

          Подход с шаблонной структурой не отличается (на выходе компилятора) от ручного создания структуры с одним полем (если ограничивается количество возможных значений для какого-то типа) и конструктором, но позволяет собрать вспомогательные функции в одном месте.

          Под вспомогательными функциями я подразумеваю как операторы чтения/записи из/в поток, сериализация/десериализация. Если для оборачиваемых типов они уже есть, то их можно будет вывести и для обернутых типов.

          Если у типа ДЕЙСТВИТЕЛЬНО есть что проверять (инварианты), то он делает это через private и конструкторы + методы

          Да, ровно это я в статье и делаю. Приватное поле + метод для создания, чтобы не бросать исключение из конструктора.


        1. Tuxman
          28.06.2023 08:32
          +1

          в С++ компилятор конечно постарается, но во первых вы добавляете лишние конвертации, во вторых запрещаете много где RVO, про то что вы из примитивных типов делаете не примитивные и во что это выльется для регистров и прочего промолчу

          RVO идёт лесом из-за возврата std::expect, вместо конкретного типа?


      1. sv_911
        28.06.2023 08:32
        +3

        А в C++, если делать подобную обертку, оптимизируют код

        В теории да, на практике не всегда. Помню, однажды в очередной версии компилятора замедлился какой-то алгоритм типа std::sort с таким типом-оберткой. В следующей версии это поправили обратно. Что это было, так и не понял

        Да говорят что и unique_ptr не максимально оптимальный. В проектах с прям серьезными требованиями по производительности его не используют


        1. Hokum Автор
          28.06.2023 08:32

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

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


    1. domix32
      28.06.2023 08:32
      +2

      А каким макаром оно на производительность влияет? Оно же на этапе компиляции по идее такое заинлайнит в честные простые типы. Можно ещё constexpr конструкторы сделать, чтобы наверняка. На сложных шаблонных типах есть нюансы, но речь вроде изначально за достаточно простые типы.

      адекватной инкапсуляции

      так речь не за инкапсуляцию, а за нормальные new types. Какие ваши предложения?


      1. Kelbon
        28.06.2023 08:32
        -5

        1. Влияет

        2. Заинлайнить тип это что-то новенькое

        3. Причём тут constexpr абсолютно непонятно

        4. Как на это влияют шаблоны тоже абсолютно непонятно

        Показываю как создать new type в С++

        struct my_new_type {

        }


        1. mayorovp
          28.06.2023 08:32

          Это не newtype в том смысле в котором его используют в ФП.


          1. Kelbon
            28.06.2023 08:32
            -6

            а я вам подсказываю, что это С++ и тут это не имеет смысла


            1. domix32
              28.06.2023 08:32
              +4

              Видимо потому что это не имеет смысла у нас есть всякие <chrono> с его seconds, miliseconds и прочими, которые оборачиваются вокруг обычных интов при помощи шаблонов.


              1. Kelbon
                28.06.2023 08:32
                -3

                template<

                    class Rep,
                    class Period = std::ratio<1>

                > class duration;

                В этом типе сохранено кроме значения ещё куча информации, а не бесполезные тег-типы


                1. Hokum Автор
                  28.06.2023 08:32
                  +1

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


                1. domix32
                  28.06.2023 08:32
                  +4

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


                  1. Kelbon
                    28.06.2023 08:32
                    -6

                    Я не говорил что тег тип портит перфоманс, он портит код. Валидацию делает обычный конструктор


            1. mayorovp
              28.06.2023 08:32
              +4

              В методиках защитного программирования и борьбы со сложностью (а newtype является одновременно и тем и другим) всегда есть смысл, в любом языке (но не всегда есть возможность, увы).


              1. Kelbon
                28.06.2023 08:32
                -5

                это усложняет, а не упрощает


                1. SergejT
                  28.06.2023 08:32
                  +1

                  Зачем ts если есть js, где все гибко?


        1. domix32
          28.06.2023 08:32
          +3

          Вопрос был каким образом влияет, а не влияет или нет.

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

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

          struct my_new_type {

          }

          Как это использовать? Большая часть кода все ещё в процессе перехода к 17 стандарту, а designated initialization только в 20+ появился, так что пользоваться по красоте что-нибудь типа `{.timestamp = 265312674}` не выйдет.


  1. sergegers
    28.06.2023 08:32
    +6

    Как идея - хорошо, как реализация - плохо. По крайней мере, для случая C++. Никто не будет использовать для замены примитивного типа уродца с std::function<> внутри, который, в общем случае, выделяет память в куче. Зато в C++ можно разрешить типу поддерживать определённый набор операций и синтаксически его использование не будет отличаться от использования встроенных типов.


    1. Hokum Автор
      28.06.2023 08:32
      +1

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

      Зато в C++ можно разрешить типу поддерживать определённый набор операций и синтаксически его использование не будет отличаться от использования встроенных типов.

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

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


      1. sergegers
        28.06.2023 08:32
        +1

        Реализацию можно и доделать под свои нужды. 

        Если ты бездомный - просто купи себе дом.

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

        Да конечно, если под каждый тип надо писать две страницы кода, никто не будет это использовать. А если это можно сделать двумя строчками кода - будут использовать все.


        1. nronnie
          28.06.2023 08:32

          В шарпе DateTime используют ведь (а внутри обычный long).


        1. Hokum Автор
          28.06.2023 08:32
          +1

          Да конечно, если под каждый тип надо писать две страницы кода, никто не будет это использовать. А если это можно сделать двумя строчками кода - будут использовать все.

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

          Увы, но прям совсем готовую отдельно стоящую библиотеку сделать для упрощения создания таких типов весьма проблематично. В каждом продукте буду свои подходы к работе с ошибками, будут свои специфичные вещи для сериализации/десериализации, правила логгирования, да и просто вкусы. :)

          А если все абстрагировать, то потом всё равно, для удобства, придется это «приземлять» на контекст проекта и всё равно писать разово дополнительный код. При этом, если идею реализовывать самостоятельно под проект, то можно сделать более оптимально как по удобству, так и по производительности.


    1. eao197
      28.06.2023 08:32

      Никто не будет использовать для замены примитивного типа уродца с std::function<> внутри, который, в общем случае, выделяет память в куче.

      +1

      ЕМНИП, в нулевых было модно экспериментировать с различными вариантами bounded_value<T, L, R> (простейший случай -- это int с ограниченным диапазоном значений, вроде bounded_value<int, 32, 64>), а так же с еще более обобщенным случаем constrained_value<T, C>, где C должен был быть типом-валидатором для значений. В самом простейшем случае это должна была быть структура со статическим методом bool check(const T&). Странно, что автор не пошел по этому простому пути, а придумал код ошибки с множественными описаниями проблем внутри + составной валидатор из цепочки std::function.


      1. Hokum Автор
        28.06.2023 08:32

        Можно и так, просто вместо списка валидаций один метод. Тут кому, что ближе и как удобнее. У меня не было цели дать готовый рецепт. Множественное описание - вопрос удобства. Зависит от того, что принято на проекте. Можно было и просто строку возвращать.

        А что касается constrained_value<T, C>, тоже можно, похожее использовал. Просто мне больше нравится когда и нижележащий тип и валидатор связаны в одном вспомогательном типе теге. Собственно задача различения двух типов так же может быть решена разными способами, о чем я и упомянул.

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

        Кому-то идея будет в новинку, кто-то, как Вы, вспомнит, что раньше похожее было. Может быть, кто-то примерит на свои задачи и эта идея сделает ежедневную рутину чуть удобнее.


        1. eao197
          28.06.2023 08:32
          +1

          Можно и так, просто вместо списка валидаций один метод. Тут кому, что ближе и как удобнее.

          ИМХО, в демонстрационных целях желательно использовать простые примеры. Так людям проще разбираться в предмете. Ваше же демонстрационное решение выглядит почти как production ready, но к нему сходу можно высказать ряд обоснованных претензий, что вы и получили.

          Да и std::expected я бы в рабочий проект пока не потащил бы

          Для старых компиляторов есть замечательный expected-lite.


        1. Tuxman
          28.06.2023 08:32
          +2

          Да и std::expected я бы в рабочий проект пока не потащил бы, так как поддерживается еще не всеми компиляторами.

          std::expect ещё долго сомневался, стать ли ему стандартом в C++23, а мы уже давно использовали реализацию от TartanLlama
          Пользуясь случаем, добавлю, что ещё мы fmtlib так тащим, потому что std::format сосёт.
          Ещё мы тащим chrono которого недопилили в C++20.
          Я уже говорил про std::ranges?
          А вообще, я зол на них, что C++20 модули завезли только в VC++.


          1. Hokum Автор
            28.06.2023 08:32

            Спасибо, что поделились, утащу в закладки к себе!


  1. TelnovOleg
    28.06.2023 08:32
    -2

    Есть такой язык - Ada. Мне кажется там есть многое из того, о чём пишет автор...


  1. Dominux
    28.06.2023 08:32
    +2

    Концепты, которые рассматривает автор, имеют мощный потенциал, однако в плане реализации он использует едва ли подходящие инструменты. Вещи, которые автор хочет, представляют лишь строготипизированные языки с жесткой проверкой компилятора. По истине почувствовать данный потенциал он бы мог на Rust, Haskell или OCaml


    1. Hokum Автор
      28.06.2023 08:32

      В указанных Вами языках это сделать можно было бы проще, хотя если сравнивать Haskell и Scala, то прям сильных отличий не вижу. Да, в Haskell есть newtype, но сам по себе они ничего не дает. Собственно как и в Scala конструкция opaque type.

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

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

      Я буду рад ошибиться в отношении этих языков. :)

      В статье у меня смешалось две вещи.

      1. Что-то типа newtype из Хаскель. Возможность создавать новые типы, которые базируются на каких-то существующих и лего различать их. В случае с Scala и Go это есть на уровне языка, а вот с C++ приходится делать обертки из структры.

      2. Как сделать так, чтобы за минимум движений потом можно было бы создавать типы обладающие сразу нужным набором свойств.

      По комментариям, в том числе и вашему, я понял, что не правильно построил статью.


  1. Tuxman
    28.06.2023 08:32

    typename Tag::Type value

    Я бы в стиле C++библиотеки назвал typename Tag::value_type


  1. Tuxman
    28.06.2023 08:32

    в языках такие проверки могут называться assert или require

    Наверное тут имелось ввиду requires из c++20


    1. mayorovp
      28.06.2023 08:32
      +2

      Нет, имелось в виду именно слово require. Оно не из C++, в C++ оно называется assert.


      1. Hokum Автор
        28.06.2023 08:32
        +1

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


  1. Tuxman
    28.06.2023 08:32
    +3

    При описании модели данных, часто приходится создавать новые типы, в первую очередь, используя такие ключевые слова как class/struct/record
    ...
    using Timestamp = int64_t;

    Настоящие герои пишут enum class Timestamp : int64_t, ещё и когда складывать начнёшь, то тебе компилятор по рукам надаёт.


    1. Hokum Автор
      28.06.2023 08:32

      Чёрт, как же сам до этого не додумался, а ведь когда-то сокрушался, что гарантий на значение enum в целом нет и туда положить можно что угодно.

      Жаль нельзя для enum указывать произвольный тип, а только целочисленный. :)


    1. Kelbon
      28.06.2023 08:32

      Не, настоящие пишут
      enum struct timestamp : std::time_t


      1. Tuxman
        28.06.2023 08:32

        Настоящие сварщики на C++20 пишут std::chrono::local_seconds, ещё чтобы и без таймзон.