Тип Option в D

Иногда, находишь нечто довольно удобное, даже в том, что тебе кардинально не нравится. Так было и со мной, поскольку работая над некоторым проектом, я осознал, что мне нужно нечто такое чего нет в D, но встроено в Rust. Он мне по некоторым причинам не нравится, в частности, тем что из-за его прихода был уничтожен проект на D, но тем не менее в Rust есть здравые идеи.

Одной из такой идей, был тип, который позволяет в зависимости от ситуации или хранить некоторое значение, или показывать отсутствие этого значения. Называются такие типы обычно или Maybe, или в случае Rust, Option. Данные типы позволяют преодолеть некоторые концептуальные проблемы и могут в целом сделать код чище.

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

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

И вот тогда, начинаются поиски вариантов… Что если нужно в функции вернуть некоторый индекс элемента и он должен быть не просто целочисленным, а неотрицательным ? Или если индекс может принимать очень большое значение, а отведенный ему знаковый тип не дает зарезервировать даже -1, как специальное значение ? А что если результат вообще нечисловой и представляет собой что-то, что может быть похоже по поведению на указатель ?

Я столкнулся с такой проблемой, когда встретился с необходимостью разобрать некий бинарный формат, который представлял собой набор строк и предполагал наличие специального пустого значения. В этой ситуации, пустая строка не может представлять это нулевое значение, поскольку сама является законным вариантом значения и есть четкая разница (прописанная в спецификации формата), которая говорит, что пустая строка и пустое значение – две разные сущности. В этот момент мне и пришла в голову мысль, что нужно нечто наподобие того, что есть в Rust…

Все дело в том, что в этом языке есть не совсем обычные enum, которые могут содержать значения разных типов, в том числе и обобщенных. Один из таких полезных типов – это тип Option, который либо содержит некоторую величину или показывает, что величины нет. Вот как этот тип определен в самом Rust, взгляните и все поймете:

enum Option<T> {
   Some(T),
   None,
}

Тип – параметризованный, и в зависимости от ситуации, его можно заполнить или некоторой величиной типа T или оставить пустышкой.

И это очень удобно, особенно, если учесть как это реализовано. А реализовано оно через tagged union – объединение типов, и его размер, если не ошибаюсь равен размеру типа T плюс 1 байт. Т.е получается, что практически никаких накладных расходов на представление пустышечного значения.

Честно говоря, на Rust, я программировал мало, но почему-то в моем парсере бинарного формата со строками пришла на ум идея именно оттуда. И я подумал, что неплохо было бы это перенести в D, с учетом того, что я пытался ранее портировать тип с похожим поведением Maybe из Haskell. Тогда когда я думал о реализации Maybe-типа, у меня еще не было твердых идей зачем это и как реализовать это, и реализация не пошла. Но в этот раз, я уже знал, как устроен Option в Rust и он мне был срочно нужен…

Требования у меня были следующие к реализации Option в D: минимальный функционал – только базовые методы, без которых уж точно не обойтись, плюс один какой-нибудь интересный метод; полное отсутствие внешних зависимостей и по возможности минимальное задействование стандартной библиотеки; простота конструкции типа – простота до такой степени, чтобы можно было воспроизвести основные элементы по памяти.

Первоначально хотелось сделать как в Rust, не смотря на то, чт мне он не нравится: а именно хотелось сделать на union. И я действительно это сделал, но при первом же применении столкнулся с проблемами упаковки туда чего-то отличного от int, и потому срочно пришлось искать иной вариант.

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

Сама структура довольно проста – это просто структура с параметром типа T. Внутри такой структуры два приватных поля: одно будет хранить величину типа T, а второе – будет содержать перечисление с двумя элементами, которое показывает, что именно мы храним: пустоту (NONE) или что-то более весомое (SOME). Для большей надежности, конструктор структуры, который принимает в качестве парметров значение типа T и перечисление делаем приватным, также внутрь самой структуры помещаем определение типа для перчисления и оставляем его в том же приватном блоке.

Приватность для конструктора и перечисления мотивирована тем, что использование структуры не предполагает прямого вызова конструктора и ручного создания чего-то с типом Option!T. Вместо этого, создание Option!T внутри которого значение (без разницы, пустое или нет) делегируется отдельным методам экземпляра, а от пользователя потребуется лишь определение самого экземпляра.

Эти методы мы назовем Some – для протаскивания в Option!T некоторого значения и None – для формирования пустого значения внутри Option!T. Кроме того, по умолчанию, определение переменной с типом Option!T уже автоматически приведет к тому, что вы получите экземпляр Option!T с заранее подготовленным значением None.

Учитывая тот факт, что эти методы не статические – не удается их сделать таковыми, от пользователя типа Option!T потребуется как минимум описать переменную данного типа и как максимум присвоить уже определенной переменной результат вызова метода Some (или, если требуется, None):

Option!string m; // по умолчанию: None

m = m.Some("test"); // задаем конкретное значение

Так как поля структуры приватны, а само по себе хранение данных в таком специфическом контейнере мало полезно, то требуется как-то узнавать что хранит в себе Option!T и как-то доставать оттуда хранимую величину.

Для этой цели я реализовал методы isSome и isNone, которые подскажут, есть ли значение внутри Option!T или перед нами пустышка.

Согласитесь, одной логической проверки тоже недостаточно и нужно каким-то образом манипулировать значением внутри Option!T. И как раз для этой цели есть два интересных метода-приема, которые активно применяются в Rust – это unwrap и orElse. Эти два метода позволят вам даже организовать обработку ошибок в Rust-стиле, а также помогут достать то, что скрывает в себе Option. Метод unwrap позволит вам извлечь то, что содержит Option!T, но если значения не окажется, то вас ждет исключение и при отсутствии его обработки ваша программа завершит работу (“запаникует”, как говорят в Rust). Метод orElse похож на unwrap, но предлагает иную стратегию: в случае, если тип Option!T окажется пустышкой, то вы можете определить некое значение по умолчанию, которое вернет метод в этом случае. Как вы понимаете, значение по умолчанию передается в метод orElse.

Теперь я могу вам показать описание типа Option!T и всех его методов, включая неописанный метод map, разбор которого я оставляю за вами:

module optibrev;

struct Option(T) 
{
	private 
	{	
		enum OptionType : byte 
	    {
	        NONE,
	        SOME
	    }
	    	
		OptionType type;
		T value;
		
		this(OptionType type, T value = T.init)
		{
			this.type  = type;
			this.value = value;
		}
	}

	bool isNone() {
		return type == OptionType.NONE;
	}
	

	bool isSome() {
		return type == OptionType.SOME;
	}
	
 
	T orElse(T defaultValue)
	{
		return (type == OptionType.NONE) ? defaultValue : value;
	}
	

	Option!T None()
	{
		return Option!T(OptionType.NONE);
	}
	

	Option!T Some(T value)
	{
		return Option!T(OptionType.SOME, value);
	}
	

    string toString() const 
	{
		import std.conv : to;
		import std.format : format;
		
		return (type == OptionType.NONE) ? "None" : format("Some(%s)", to!string(value));
	}
    
    
    T unwrap() const 
    {
        if (type == OptionType.SOME)
        {
            return value;
        }
        
        throw new Exception("Can't unwrap None"); 
    }
    
    Option!U map(U)(U delegate(T) func) 
	{ 
		Option!U tmp;
		
	    if (type == OptionType.NONE) 
	    {
	        tmp = tmp.None(); 
	    } 
	    else 
	    {
	        tmp = tmp.Some(func(value)); 
	    } 
	    return tmp; 
	}
}

Очень простая и маленькая реализация (84 строки, если вырезать комментарии). Скорее всего это будет работать даже в BetterC, но честно говоря, я не проверял.

Также я бы хотел сказать то, что весь этот код может быть целиком вставлен в любой проект, однако, можно этого не делать и просто добавить нашу библиотеку под названием optibrev. В ней тот же самый код, который позволит вам формировать Option!T, проверять его, извлекать из него хранимые данные, а также выводить на печать и протаскивать в функции. В библиотеке есть метод о котором я не говорил – это map, который позволит вам применить функцию к значению в Option!T не раскрывая при этом самого Option, возможно, в перспективе будет добавлен похожий метод flatMap, который уже принимает функцию непосредственно работающую с Option!T и избавляет от вложений Option’ов.

Пара слов о том, а для чего все это. Во первых, это интересно, и мы уже довольно давно заглядываемся на разные интересные штуки из других языков программирования. Во вторых, реализация подобных вещей – это дело пары минут и почти не требует навыка, что кстати, косвенно подтверждается наличием нескольких подобных библиотек в dub. В третьих (и это последний пункт) – это очень полезная вещь, которая может упростить обработку ошибок и помогает сгладить концептуальное пятно в функциях, которые к примеру что-то ищут в структурах данных и должны как-то показать результат или его отсутствие. Также , по крайней мере так утверждают в некоторых кругах, использование Option может помочь отказаться от null значений и тем самым сократить большое количество ошибок, связанных с неверной обработкой пустых значений.

На этом все, но хотелось бы спросить, а как бы применили Option вы ?