Введение
При описании модели данных, часто приходится создавать новые типы, в первую очередь, используя такие ключевые слова как 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)
ChessMax
28.06.2023 08:32+2Спасибо за статью. Интересно, что буквально вчера я опубликовал статью на эту же тему, только в других языках.
Tuxman
28.06.2023 08:32-2Dart? Язык немного взлетает только благодаря Flutter.
Отвечу сюда, потому что для C++ такое нагромождение шаблонной магии ещё как-то можно оправдать, но Dart, как замена JS, там порог вхождения в отрасль чуть ниже, так что вы их только напугаете.
Kelbon
28.06.2023 08:32-1Подобное использование "типов" абсолютно бесполезно и ухудшает код и производительность
То что тут происходит заменяется на имена, т.к. эти типы не держат никаких дополнительных инвариантов над тем что хранят
То, что написано в последней трети это вообще, простите(нет), мусор. using Result = expected и Type::New неоднозначно намекают откуда копируется мусор.
Просто напомню, что в С++ существуют конструкторы и ещё миллиард способов для создания адекватной инкапсуляции
Hokum Автор
28.06.2023 08:32+1Производительность в случае со Scala не уменьшается. В отношении Go, если использовать просто type definition, то не должно влиять. А в C++, если делать подобную обертку, оптимизируют код. Без такой оптимизации, тот же
std::unque_ptr
был бы крайне неэффективным. Если говорить о простых типах типаint
, то разницы совсем не будет. В случае с более сложными, какstd::string
, да, появляются некоторые накладные расходы. Можно посмотреть результат компиляции: https://godbolt.org/z/7o8bP91jzЧто до ухудшения кода - это скорее понятие вкуса. Кто-то считает излишеством, кому-то нравится. Мне и команде на одном из проектов подобное понравилось и мы активно использовали. В других командах к таким предложения относились скептически.
Ну и как раз подобные обертки и позволяют контролировать возможные инварианты значений, чего просто имена не дадут.
Kelbon
28.06.2023 08:32-1производительность в скале просто по умолчанию неочень
в С++ компилятор конечно постарается, но во первых вы добавляете лишние конвертации, во вторых запрещаете много где RVO, про то что вы из примитивных типов делаете не примитивные и во что это выльется для регистров и прочего промолчу
Этот код просто напросто не несёт никакой смысловой нагрузки, он бесполезен
Если у типа ДЕЙСТВИТЕЛЬНО есть что проверять (инварианты), то он делает это через private и конструкторы + методы
-
первые две трети статьи заменяются на именованные аргументы функций
struct foo_named_args { int v1; float v2; }; void foo(foo_named_args) {} int main() { foo({.v1 = 5, .v2 = 3.14}); }
Hokum Автор
28.06.2023 08:32+2Правильно именованные аргументы, не всегда спасают, так как при совпадении типов компилятору всё равно какое имя. Так же может сработать неявное приведение, которое не всегда желательно.
Подход с шаблонной структурой не отличается (на выходе компилятора) от ручного создания структуры с одним полем (если ограничивается количество возможных значений для какого-то типа) и конструктором, но позволяет собрать вспомогательные функции в одном месте.
Под вспомогательными функциями я подразумеваю как операторы чтения/записи из/в поток, сериализация/десериализация. Если для оборачиваемых типов они уже есть, то их можно будет вывести и для обернутых типов.
Если у типа ДЕЙСТВИТЕЛЬНО есть что проверять (инварианты), то он делает это через private и конструкторы + методы
Да, ровно это я в статье и делаю. Приватное поле + метод для создания, чтобы не бросать исключение из конструктора.
Tuxman
28.06.2023 08:32+1в С++ компилятор конечно постарается, но во первых вы добавляете лишние конвертации, во вторых запрещаете много где RVO, про то что вы из примитивных типов делаете не примитивные и во что это выльется для регистров и прочего промолчу
RVO идёт лесом из-за возврата std::expect, вместо конкретного типа?
sv_911
28.06.2023 08:32+3А в C++, если делать подобную обертку, оптимизируют код
В теории да, на практике не всегда. Помню, однажды в очередной версии компилятора замедлился какой-то алгоритм типа std::sort с таким типом-оберткой. В следующей версии это поправили обратно. Что это было, так и не понял
Да говорят что и unique_ptr не максимально оптимальный. В проектах с прям серьезными требованиями по производительности его не используют
Hokum Автор
28.06.2023 08:32Ну потому, что у него под капотом примерно то же самое. :) И да, встаем перед выбором сделать что-то оптимальнее с точки зрения производительности или уменьшить вероятность ошибки программистом, но ценой замедления программы.
Если у проекта такие требования к производительности, что unique_ptr не используется, то там, скорей всего, и вся стандартная библиотека не используется тоже, у них есть своя стандартная библиотека. Но есть проекты и с менее критичными требованиями к производительности.
domix32
28.06.2023 08:32+2А каким макаром оно на производительность влияет? Оно же на этапе компиляции по идее такое заинлайнит в честные простые типы. Можно ещё constexpr конструкторы сделать, чтобы наверняка. На сложных шаблонных типах есть нюансы, но речь вроде изначально за достаточно простые типы.
адекватной инкапсуляции
так речь не за инкапсуляцию, а за нормальные new types. Какие ваши предложения?
Kelbon
28.06.2023 08:32-5Влияет
Заинлайнить тип это что-то новенькое
Причём тут constexpr абсолютно непонятно
Как на это влияют шаблоны тоже абсолютно непонятно
Показываю как создать new type в С++
struct my_new_type {
}mayorovp
28.06.2023 08:32Это не newtype в том смысле в котором его используют в ФП.
Kelbon
28.06.2023 08:32-6а я вам подсказываю, что это С++ и тут это не имеет смысла
domix32
28.06.2023 08:32+4Видимо потому что это не имеет смысла у нас есть всякие
<chrono>
с его seconds, miliseconds и прочими, которые оборачиваются вокруг обычных интов при помощи шаблонов.Kelbon
28.06.2023 08:32-3template<
class Rep,
class Period = std::ratio<1>> class duration;
В этом типе сохранено кроме значения ещё куча информации, а не бесполезные тег-типыHokum Автор
28.06.2023 08:32+1Тег-типы позволяют описать ограничения значений плюс, так в результате получается новый тип, модно определить функции для работы с ним. Это все можно сделать и руками для каждого нового типа или единожды обговорив способы обработки ошибок вынести в общую часть.
domix32
28.06.2023 08:32+4Так суть та же. Тег тип нужен только для статической валидации чтобы компилятор не давал складывать гадюк с жабами. Накладных расходов в рантайме он не добавляет, зато избавляет от проблем с валидацией.
Kelbon
28.06.2023 08:32-6Я не говорил что тег тип портит перфоманс, он портит код. Валидацию делает обычный конструктор
mayorovp
28.06.2023 08:32+4В методиках защитного программирования и борьбы со сложностью (а newtype является одновременно и тем и другим) всегда есть смысл, в любом языке (но не всегда есть возможность, увы).
domix32
28.06.2023 08:32+3Вопрос был каким образом влияет, а не влияет или нет.
Шаблоны разворачиваются на этапе мономорфизации типов и после проверок соответствия превращаются в самые простые типы - то что я назвал инлайнингом, хотя корректнее называть это type elimination. То есть результирующий код будет как минимум не медленнее чем если ткнуть туда обычные простые типы, а в некоторых ещё и быстрее, если ньютайп позволил наложить дополнительные ограничения и сгенерировать радость для branch prediction.
Собираться такой код будет несколько дольше, понимать такой код возможно будет сложнее, но производительность не страдает никак - плюсы и изобретались для таких бесплатных абстракций. То есть страдает продуктивность разраба, а не производительность кода.
struct my_new_type {
}
Как это использовать? Большая часть кода все ещё в процессе перехода к 17 стандарту, а designated initialization только в 20+ появился, так что пользоваться по красоте что-нибудь типа `{.timestamp = 265312674}` не выйдет.
sergegers
28.06.2023 08:32+6Как идея - хорошо, как реализация - плохо. По крайней мере, для случая C++. Никто не будет использовать для замены примитивного типа уродца с std::function<> внутри, который, в общем случае, выделяет память в куче. Зато в C++ можно разрешить типу поддерживать определённый набор операций и синтаксически его использование не будет отличаться от использования встроенных типов.
Hokum Автор
28.06.2023 08:32+1Реализацию можно и доделать под свои нужды. Как один из вариантов - в структуру тег добавить функцию, которая и будет выполнять проверку. Вариантов реализации масса.
Зато в C++ можно разрешить типу поддерживать определённый набор операций и синтаксически его использование не будет отличаться от использования встроенных типов.
Да, можно. В каких-то случаях это действительно нужно и иногда даже делают, но чаще в виде отдельных библиотек. Чтобы в каком-то конкретном продукте создали тип с операциями и потом его использовали - не встречалось.
У меня на практике, чаще была необходимость различать идентификаторы разных сущностей, а там нет необходимости поддерживать много операций.
sergegers
28.06.2023 08:32+1Реализацию можно и доделать под свои нужды.
Если ты бездомный - просто купи себе дом.
Да, можно. В каких-то случаях это действительно нужно и иногда даже делают, но чаще в виде отдельных библиотек. Чтобы в каком-то конкретном продукте создали тип с операциями и потом его использовали - не встречалось.
Да конечно, если под каждый тип надо писать две страницы кода, никто не будет это использовать. А если это можно сделать двумя строчками кода - будут использовать все.
Hokum Автор
28.06.2023 08:32+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.Hokum Автор
28.06.2023 08:32Можно и так, просто вместо списка валидаций один метод. Тут кому, что ближе и как удобнее. У меня не было цели дать готовый рецепт. Множественное описание - вопрос удобства. Зависит от того, что принято на проекте. Можно было и просто строку возвращать.
А что касается
constrained_value<T, C>
, тоже можно, похожее использовал. Просто мне больше нравится когда и нижележащий тип и валидатор связаны в одном вспомогательном типе теге. Собственно задача различения двух типов так же может быть решена разными способами, о чем я и упомянул.Да и
std::expected
я бы в рабочий проект пока не потащил бы, так как поддерживается еще не всеми компиляторами. Я не претендую на истину в последней инстанции, лишь показываю свое виденье того как можно. Важна идея, потенциальная возможность сделать что-то, а реализацию каждый подберет для себя, если захочет.Кому-то идея будет в новинку, кто-то, как Вы, вспомнит, что раньше похожее было. Может быть, кто-то примерит на свои задачи и эта идея сделает ежедневную рутину чуть удобнее.
eao197
28.06.2023 08:32+1Можно и так, просто вместо списка валидаций один метод. Тут кому, что ближе и как удобнее.
ИМХО, в демонстрационных целях желательно использовать простые примеры. Так людям проще разбираться в предмете. Ваше же демонстрационное решение выглядит почти как production ready, но к нему сходу можно высказать ряд обоснованных претензий, что вы и получили.
Да и std::expected я бы в рабочий проект пока не потащил бы
Для старых компиляторов есть замечательный expected-lite.
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++.
TelnovOleg
28.06.2023 08:32-2Есть такой язык - Ada. Мне кажется там есть многое из того, о чём пишет автор...
Dominux
28.06.2023 08:32+2Концепты, которые рассматривает автор, имеют мощный потенциал, однако в плане реализации он использует едва ли подходящие инструменты. Вещи, которые автор хочет, представляют лишь строготипизированные языки с жесткой проверкой компилятора. По истине почувствовать данный потенциал он бы мог на Rust, Haskell или OCaml
Hokum Автор
28.06.2023 08:32В указанных Вами языках это сделать можно было бы проще, хотя если сравнивать Haskell и Scala, то прям сильных отличий не вижу. Да, в Haskell есть newtype, но сам по себе они ничего не дает. Собственно как и в Scala конструкция
opaque type
.Т.е. если хочется, чтобы новый тип обладал некоторыми свойствами: сериализация/десериализация в нужном формате, единообразная валидация при создании, как логировать. Это всё равно придется делать в ручную для каждого такого типа, писать тайп классы.
Если хочется сделать что-то подобное, то и в указанных Вами языках придется делать некоторую абстракцию, которая позволит собрать все части во едино. Хочется не просто иметь возможность объявить новый тип, но чтобы он сразу подхватил все необходимые в рамках проекта свойства.
Я буду рад ошибиться в отношении этих языков. :)
В статье у меня смешалось две вещи.
Что-то типа newtype из Хаскель. Возможность создавать новые типы, которые базируются на каких-то существующих и лего различать их. В случае с Scala и Go это есть на уровне языка, а вот с C++ приходится делать обертки из структры.
Как сделать так, чтобы за минимум движений потом можно было бы создавать типы обладающие сразу нужным набором свойств.
По комментариям, в том числе и вашему, я понял, что не правильно построил статью.
Tuxman
28.06.2023 08:32typename Tag::Type value
Я бы в стиле C++библиотеки назвал
typename Tag::value_type
Tuxman
28.06.2023 08:32в языках такие проверки могут называться assert или require
Наверное тут имелось ввиду
requires
из c++20
Tuxman
28.06.2023 08:32+3При описании модели данных, часто приходится создавать новые типы, в первую очередь, используя такие ключевые слова как class/struct/record
...using Timestamp = int64_t;
Настоящие герои пишут
enum class Timestamp : int64_t
, ещё и когда складывать начнёшь, то тебе компилятор по рукам надаёт.Hokum Автор
28.06.2023 08:32Чёрт, как же сам до этого не додумался, а ведь когда-то сокрушался, что гарантий на значение enum в целом нет и туда положить можно что угодно.
Жаль нельзя для enum указывать произвольный тип, а только целочисленный. :)
ChessMax
Спасибо за статью. Интересно, что буквально вчера я опубликовал статью на эту же тему, только в других языках.