
Как избежать путаницы id, или что делать если от int нельзя наследоваться.

Вступление
Трудно представить себе игру в которой бы не использовалось ни единого id, «айдишки» — члена класса используемого для хранения уникального идентификатора любой игровой сущности, например врагов, пуль, предметов инвентаря, городов и тп. Если же говорить об игре взаимодействующей с сервером то важность id там еще более велика, так как при любых действиях со стороны игрока именно id уходит на сервер в одном из параметров вызванного серверного метода чтобы указать что именно сделал игрок.
Проблема
В последнее время я столкнулся с тем что начали путаться id от разных сущностей, что вызвало проблемы. Например есть армия, у нее свой id, а так же в ней есть герой у которого тоже id. Метод который дергаем на сервере чтобы армия отправилась в путь содержит id армии, а метод который надевает экипировку на героя содержит айди героя. И таких соседствующих айди в проекте довольно много. Начали возникать ситуации когда ошибочно в метод который отправляет данные на сервере вместо id героя начали попадать id армии и тп. Думаю понятно что обращение к серверу с несуществующим или чужим айди не может не вызвать ошибок. Потому встала задача разделить id каким то образом и возложить обязанности контроля за этим разделением на компилятор.перефразируя задачу приведу пример:
вместо
setArmyPath(_id: int,_path: Path): void {};
wearEquipment(_inventoryId : int, _heroId : int, _slothIndex : int) : void {};
я решил сделать так:
setArmyPath(_id: IdArmy,_path: Path): void {};
wearEquipment(_inventoryId : IdInventory,
_heroId : IdHero, _slothIndex : int) : void {};
с тем чтобы компилятор сам пресекал попытки передать какую либо неподходящую id в аргумент который требует например именно id армии.
Задумка понравилась, настала очередь поиска путей реализации.
Естественно первой мыслью было наследовать новый класс от int, но я уже и раньше знал что это невозможно.
Следовательно если нельзя наследоваться от int то нужно обернуть int в обертку и использовать обертку вместо int-а всюду где не нужно оперировать с id как числом, а когда это понадобится получать int по геттеру из обертки и использовать как число. Вот примерно так:
var pureId:int = myIdWrapper.value;
На первый взляд ничего сложного тут нет, но поразмыслив я понял что столкнусь с трудностями.
Трудности
Первая основополагающая трудность заключалась в том что в коде было уже полно сравнений "==" айдишек. С int тут все просто и естественно, возьмите две переменных типа int, присвойте им обоим 4 и сравнение покажет что они равны. А вот стоит сделать две обертки над одним и тем же интом и это будут совершенно разные объекты, сравнение между которыми покажет false (еще бы, ведь будут сравниваться значения указателей на объекты а не числа внутри объектов). Если бы я писал такое на C# то это решилось бы очень просто — перегрузкой оператора "==". Перегрузил бы и сравнил внутри оператора значения инта внутри обертки.Вариант пройти по коду и заменить все "==" на что-то вроде
if(myIdVariable1.isEquals(myIdVariable2))
{
...
}
отпал сразу, так как даже если это и сделать (что очень не просто, так как нет возможности быстро искать ссылки на оператор, как на переменную или метод) то все равно нет гарантии что потом я (или другой программист) не забудет об этом ограничении и не напишет снова обычное сравнение "==", что вызовет труднонаходимую логическую ошибку. Потому было принято решение обеспечить работоспособность уже существующих сравнений "==" и это вызвало вторую трудность.
Вторая трудность вытекала из задачи — надо сравнивать объекты содержащие int как сами int-ы и чтобы сравнение двух объектов с одинаковым числом внутри вернуло true. Сделать это можно только одним способом — гарантировать что существует только один экземпляр объекта обертки для каждого числа и на этот единственный экземпляр указывают обе сравниваемые переменные. Следовательно надо помешать пользователю создать две обертки с одним числом внутри. Вообщем не уверен что вам будет интересен весь ход рассуждений потому расскажу идею которая сформировалась в их результате:
Обеспечиваем наличие только одного экземпляра обертки для каждого значения int: Сделаем приватным конструктор класса обертки. Экземпляры же будем получать из статического метода класса обертки передавая ему int а на выходе получая обертку. Внутри класса обертки будет храниться массив, в котором в ячейках хранятся созданные экземпляры, индексом массива является int который мы оборачиваем в обертку. Каждый раз когда мы вызываем статический метод создания обертки, проверяется ячейка массива по указанному int и если там пусто, то создается новая обертка, помещается в ячейку и возвращается методом. Если же там уже есть обертка (если этот int уже оборачивался) то просто достается существующая обертка и возвращается статическим методом под видом новой. Таким образом две обертки созданные из одного int обязательно будут являться одним и тем же объектом и могут сравниваться оператором "==".
Треья трудность (сделать приватным конструктор класса) таковой не являлась но если кто-то не знает как в ActionScript 3 это сделать то охотно расскажу. Опять таки если бы я писал на C# все было бы куда проще- приватный конструктор и все. Но раз мы пишем на as3 то так просто не получится и такая ситуация заставляет нас прибегнуть к хитрости. Обычно подобные манипуляции выполняют при создании Singleton-ов, есть несколько методов «приватизации» конструктора, будем использовать самый лучший на мой взгляд.
как закрыть конструктор(метод придумал не я). Внизу класса который закрываем, за пределами пакейджа создаем локальный класс. И именно этот класс мы просим передать внутрь конструктора закрываемого класса. Пользователь извне класса физически не сможет его создать чтобы передать в конструктор (и ему придется полезть и почитать комментарий к конструктору, в котором будет описано как правильно все создать). Единственное что он сможет это попробовать впихнуть туда null и это мы уже должны будем в конструкторе отловить проверкой и выдать Error.
package { public class IdWrapper { public function IdWrapper(_lock:LockIdWrapper){ if (_lock==null) throw new Error("этот класс нельзя создавать через конструктор!"); } } } class LockIdWrapper{ }
Решение
В горниле программинга родился класс приведенный ниже, который позволяет относительно легко и просто создавать обертки над int для использования как id:package
{
import flash.utils.Dictionary;
import flash.utils.getDefinitionByName;
import flash.utils.getQualifiedClassName;
/**
* @author Troglodit
* http://blog.hamsterwarrior.com
* базовая обертка для числовых id
*/
public class IdBase
{
// Строка содержащая все примечания инициализации id.
// Нужна для эфективного поиска "концов"
private var mPaths : String = "";
// Числовой значение id
private var mValue : int;
// Пустое значение IdBase. Не инициализируется прямо
// тут тк это вызовет ошибку
static private var mEmpty : IdBase;
// Справочник массивов id ключ=класс насследованный от IdBase
static private var mClassDictionary : Dictionary = new Dictionary();
/** Пустое значение IdBase, использовать для инициализации,
значения по умолчанию и тп.*/
static public function get empty() : IdBase
{
if (mEmpty == null)
{
mEmpty = new IdBase(new lock_IdBase(), "null", 0);
}
return mEmpty;
}
public function IdBase(_lock : Object, _amfPath : String, _value : int)
{
var clas : Class = getDefinitionByName(getQualifiedClassName(this)) as Class;
if (clas == IdBase)
{
if (!(_lock is lock_IdBase))
{
throw new Error(
"_lock в конструкторе IdBase должен быть класса lock_IdBase"
);
}
}
else if (_lock == null)
{
throw new Error("_lock не должен быть null");
}
mPaths += _amfPath + "\n";
mValue = _value;
}
/**Добавить строку примечаний для даннго id*/
private function addPath(_amfPath : String) : void
{
// Директива условной компиляции, позволяет отключить
// сохранение примечаний внутри id
config::isAllowSavingNotesInIdWrapper
{
if (mPaths.indexOf(_amfPath) < 0)
mPaths += _amfPath + "\n";
}
}
/** Числовое значение айди (то что мы обертали в обертку)*/
public function get value() : int
{
return mValue;
}
/**
* метод возвращает IdBase для переданного int
* @param _id - число которое оборачиваем в IdBase
* @param _amfPath - опциональная строка, позволяющая сохранить
* в IdBase данные о обстоятельствах создания этого экземпляра.
*/
static public function fromInt(_id : int, _amfPath : String="") : IdBase
{
return IdBase.fromIntBase(IdBase, _id, _amfPath, new lock_IdBase()) as IdBase;
}
/**
* Метод возвращает Array айдишек для указанного класса.
* Если для этого класса еще нет айдишек метод создает его
* @param _class Класс, наследующий IdBase
* @return массив айдишек где хранятся все созданные айдишки.
* Интексом массива
* для айдишки является ее числовое значение
*/
static private function getArrayOfGivenClass(_class : Class) : Array
{
var arr : Array = mClassDictionary[_class];
if (arr == null)
{
arr = [];
mClassDictionary[_class] = arr;
}
return arr;
}
/**
* метод вносит вносит новую айди в справочник и контролирует чтобы
* локер класса не был равен null
* @param _class Класс, наследующий IdBase
* @param _id число которое мы помещаем в обертку
* @param _path примечание, путь откуда пришла id
* @param _lock локер конструктора, нужен для успешного создания
* класса наследующего IdBase
*/
static public function fromIntBase(_class : Class,_id : int,_path : String,_lock : Object) : IdBase
{
// Получаем массив id для данного класса
var mArr : Array = getArrayOfGivenClass(_class);
// пытаемся получить существующую обертку для этого числа
var currentId : IdBase = mArr[_id];
// Если такого айди еще не внесено в справочник
if (currentId == null)
{
// Создаем новую обертку
currentId = new _class(_lock, _path, _id);
// Сохраняем
mArr[_id] = currentId;
}
else
{
// Добавляем примечание
currentId.addPath(_path);
}
return currentId;
}
public function toString() : String
{
return value.toString();
}
}
}
class lock_IdBase
{
}
Пример
Прежде чем показать пример применения хочу сказать еще кое что. Напоминаю, что целью всего нашего изыскания было заставить компилятор контролировать разновидности наших id и не позволять им поступать в функции которые требуют id другого класса. исходя из этого каждая реализация класса id будет являться по сути типизирующей оберткой над своим базовым классом, тк компилятору нужно будет различать классы реализаций. Кроме того все реализации id будут созданы по одному шаблону и отличаться только названием класса. Поэтому я рекомендую использовать для их создания любой инструмент генерации кода по шаблону, такие есть почти во всех IDE.Вот пример шаблона нашей обертки для FDT:
package ${enclosing_package}
{
/**
* Специализированный контейнер id.
* Обертка над int позволяющая делать как бы разные типы интов,
* чтобы не путать id сущностей разных видов. Позволяет отслеживать места
* где id инициализировалась из инта и вести историю таких инициализаций
*
* КЛАСС НЕ РЕДАКТИРОВАТЬ
*/
public class ${enclosing_type} extends IdBase
{
public function ${enclosing_type}(_lock : lock${enclosing_type},_amfPath : String,_value : int)
{
super(_lock, _amfPath, _value);
}
public static function fromInt(_id : int,_amfPath : String) : ${enclosing_type}
{
return IdBase.fromIntBase(
${enclosing_type},
_id,
_amfPath,
new lock${enclosing_type}()) as ${enclosing_type};
}
public static function get empty() : ${enclosing_type}
{
return fromInt(0, "empty_${enclosing_type}");
}
}
}
class lock${enclosing_type}
{
}
Сохраним этот шаблон в списке шаблонов под именем IdWrapper
Создаем класс с именем IdSample, удалим из него все и вызовем генерацию шаблона. Вот что мы получим.
package
{
/**
* Специализированный контейнер id.
* Обертка над int позволяющая делать как бы разные типы интов,
* чтобы не путать id сущностей разных видов. Позволяет отслеживать места
* где id инициализировалась из инта и вести историю таких инициализаций
*
* КЛАСС НЕ РЕДАКТИРОВАТЬ
*/
public class IdSample extends IdBase
{
public function IdSample(_lock : lockIdSample,_amfPath : String,_value : int)
{
super(_lock, _amfPath, _value);
}
public static function fromInt(_id : int,_amfPath : String) : IdSample
{
return IdBase.fromIntBase(
IdSample,
_id,
_amfPath,
new lockIdSample()) as IdSample;
}
public static function get empty() : IdSample
{
return fromInt(0, "empty_IdSample");
}
}
}
class lockIdSample
{
}
Я постарался свести текст реализации к минимуму, почти весь код перенеся в базовый класс IdBase.
Перевод существующего кода под типизированные id
Эти шаги Вам помогут если вы захотите применить мой метод работы в id в существующем проекте.- Подключаем к проекту класс IdBase
- Создаем шаблон кода указанный выше.
- Находим переменную id типа int которую хотим типизировать.
- Исходя из имени класса который ее содержит генерируем для нее шаблоном типизированный id (например IdArmy), если нужно добавляем import-ы (если реализация будет в другом пакейдже чем IdBase)
- Изменяем тип выбранной переменной с int на IdArmy. Тут же получаем от компилятора кучу ошибок. Ошибки будут трех типов — во первых в местах где переменной присваивается число. Здесь мы используем IdArmy.fromInt чтобы обернуть число в нашу типизированную id. Во вторых ошибки будут в местах где выполняются действия над значением переменной (например отправка на сервер). Здесь мы используем IdArmy.value чтобы получить числовое значение id. И в третьих ошибки будут в типах аргументов методов и типах переменных других классов где эта id так же может встречаться. Здесь следует заменить тип всех переменных с int на IdArmy.
Определить же тип id хранящегося в такой переменной труда не составит — оператор «is» или getQualifiedClassName.
Ограничения
Довольно тяжела в плане быстродействия функция fromInt но в идеале ее нужно вызвать лишь раз при инициализации id. Когда же id создана она должна работать практически так же быстро как int в присваивании и сравнении. Также не тяжел геттер выдающий числовое значение.Еще следует сказать что нужно проявлять осторожность в указании примечаний при создании оберток. Они будут полезны для определения например времени создания id или сохранения номера/имени серверного запроса из которого пришло числовое значение id. Все это здорово поможет при отладке когда надо будет быстро найти откуда же пришел этот id. Однако если таких точек инициализации будет много и строки примечаний будут уникальными (если повторяющимися — не страшно) они раздуют строку которая хранит эти примечания и могут возникнуть проблемы с памятью. Для предотвращения этого я ввел директиву компиляции isAllowSavingNotesInIdWrapper в классе IdBase, ее можно включить в отладочной версии и выключить в релизной, тогда не будут сохраняться примечания но и утечки не будет гарантированно.
В добавок было бы неплохо произвести некоторые действия для обеспечения неприкосновенности классов id, например завернуть их в swc, впрочем это не обязательно.
Заключение
Кому-то, возможно, покажется что игра не стоит свеч, так возиться с какими то там id. Отвечу на это что целесообразность такого нововведения прямо пропорциональна количеству кода в проекте и резко возрастает если проект клинт-серверный. В моей команде результат пошел «на ура», он сразу позволил нам выявить несколько скрытых проблем о которых мы не подозревали. Кроме того теперь четко видно какой именно id хочет получить функция и не приходится предварительно читать доку по методу или вспоминать и сомневаться правильно ли вспомнил.Статья получилась гораздо длиннее чем рассчитывал, извините если слишком рассусоливал очевидности, просто я пытался сделать статью максимально понятной. Спасибо что дочитали сюда :) Надеюсь информация из статьи вам пригодится. Удачи!
Согласно пункту 6 правил даю ссылку на эту статью на моем блоге.
- +7
- Troglodit
Комментарии (6)
А можно стратегический вопрос? Сразу скажу, что «легко быть филином» и я ни сколько не считаю, что ты что-то в текущем проекте сделал плохо. ;-)
Тебе не кажется, что для следующего проекта имеет смысл избавиться от повсеместного использования айдишников? Просто вот смотри, ты говоришь: «Трудно представить себе игру в которой бы не использовалось ни единого id». Но для чисто клиентской игры, например, это не совсем так. Там скорее наоборот трудно придумать, для чего бы айдишники были нужны. ;-)
На мой взгляд, айдишники возникают естественным образом в основном из суррогатных ключей в базе данных. Причем их создает всегда сервер. Т.е. ты сделал createHero — она вернула ID. Потом в функции killHero его передаешь.
Соответственно в принципе ничто не мешает в функции createHero вернуть собственно экземпляр класса Hero. А функции killHero его принимать на вход.
Тогда с числовыми ID из класса Hero будет работать только узкая серверная прослойка.
Теперь по поводу того чтобы killHero принимала Hero — считаю что это будет значительное ухудшение инкапсуляции. В функцию которой нужно всего одно число id прилетит весь Hero, со всем инвентарем, шмотом и характеристиками. Ну а зачем нужна инкапсуляция и что будет если ее нарушать думаю и так понятно.
Насчет того чтобы createHero возвращала Hero — то же самое только с другой стороны. Функции которая всего то принимает с сервера и возвращает VO по которому создастся герой не должна ничего знать о том как герой создается, ее дело корректная работа с сервером. Опять инкапсуляция лучше если использовать id.
Я согласен что id надо использовать не тотально а взвешивать целесообразность его использования, я об этом писал в статье.
Если честно, я тут не вижу нарушения инкапсуляции. Функция killHero с аргументом «Hero» реализует согласованную абстракцию. Тот факт, что для ее работы сегодня из всего героя нужен только ID — деталь реализации. Может оказаться, что завтра таких айдишников будет два (идентификатор мира, скажем). А послезавтра в целях лучшей устойчивости к перебору мы захотим использовать guid.
Тем более кажется, что работа с объектами напрямую уместна в собственно «геймплейных» метода типа wearEquipment. Кажется даже, что такой метод лучше прямо у героя и делать. Передаем туда Equipment и все нормально.
Еще раз повторю — это все не в порядке спора. Спорить можно о конкретной задаче, да и тогда есть more than one way to skin a cat. Но во многих таких приложениях построить этакий ORM фреймворк поверх сервера (и скрыть детали реализации типа айдишников) — вполне уместная идея.
Но вариат Sbat мне кажется привлекательней. Во первых, я бы добавил интерфейс IIdObject,IIndexed или как-то на подобии. А во-вторых объекты с одинаковыми id не должны создаваться в виде несокольких разных экземпляров — поэтому тут простое сравнение объектов (которое, кстати быстрей сравнения строк) будет вполне достаточно.