Pull to refresh

Как жить без const?

Reading time 5 min
Views 14K
Часто, передавая объект в какой-либо метод, нам бы хотелось сказать ему: «Вот, держи этот объект, но ты не имеешь право изменять его», и как-то отметить это при вызове. Плюсы очевидны: помимо того, что код становится надёжнее, он становится ещё и более читаемым. Нам не нужно заходить в реализацию каждого метода, чтобы отследить, как и где изменяется интересующий нас объект. Более того, если константность передаваемых аргументов указана в сигнатуре метода, то по самой такой сигнатуре, с той или иной точностью, уже можно предположить, что же он собственно делает. Ещё один плюс – потокобезопасность, т.к. мы знаем, что объект является read only.
В C/C++ для этих целей существует ключевое слово const. Многие скажут, что такой механизм слишком ненадёжен, однако, в C# нет и такого. И возможно он появится в будущих версиях (разработчики этого не отрицают), но как же быть сейчас?


1. Неизменяемые объекты (Immutable objects)

Самый известный подобный объект в C# — это строка (string). В нём нет ни одного метода, приводящего к изменению самого объекта, а только к созданию нового. И всё с ними вроде бы хорошо и красиво (они просты в использовании и надёжны), пока мы не вспомним о производительности. К примеру, найти подстроку можно и без копирования всего массива символов, однако, что если нам нужно, скажем, заменить символы в строке? А что если нам нужно обработать массив из тысяч таких строк? В каждом случае будет производиться создание нового объекта строки и копирование всего массива. Старые строки нам уже не нужны, но сами строки ничего не знают об этом и продолжают копировать данные. Только разработчик, вызывая метод, может давать или не давать право на изменение объектов-аргументов. К тому же, использование неизменяемых объектов никак не отражено в сигнатуре метода. Как же нам быть?

2. Интерфейс

Один из вариантов – создать для объекта read only интерфейс, из которого исключить все методы, изменяющие объект. А если этот объект является generic’ом, то к интерфейсу можно добавить ещё и ковариантность. На примере с вектором это будет выглядеть так:

interface IVectorConst<out T>
{
    T this[int nIndex] { get; }
}

class Vector<T> : IVectorConst<T>
{
    private readonly T[] _vector;

    public Vector(int nSize)
    {
        _vector = new T[nSize];
    }

    public T this[int nIndex]
    {
        get { return _vector[nIndex]; }
        set { _vector[nIndex] = value; }
    }
}

void ReadVector(IVectorConst<int> vector)
{
   ...
}


(Кстати, между Vector и IVectorConst (или IVectorReader – кому как нравится) можно добавить ещё и контравариантный IVectorWriter.)

И всё бы ничего, но ReadVector’у ничто не мешает сделать downcast к Vector и изменить его. Однако, если вспомнить const из C++, данный способ ничем не менее надёжен, как столь же ненадёжный const, никак не запрещающий любые преобразования указателей. Если вам этого достаточно, можно остановиться, если нет – идём дальше.

3. Отделение константного объекта посредством «Адаптера»

Запретить вышеупомянутый downcast мы можем только одним способом: сделать так, чтобы Vector не наследовал от IVectorConst, то есть отделить его. Один из способов сделать это — создать «Адаптер» VectorConst (спасибо osmirnov за то, что напомнил об этом способе, без него статья была бы неполной). Выглядеть это будет подобным образом:

interface IVector<in T>
{
    T this[int nIndex] { set; }
}

interface IVectorConst<out T>
{
    T this[int nIndex] { get; }
}

class VectorConst<T> : IVectorConst<T>
{
    private readonly Vector<T> _vector;

    public VectorConst(Vector<T> vector)
    {
        _vector = vector;
    }

    public T this[int nIndex]
    {
        get { return _vector[nIndex]; }
    }
}

class Vector<T> : IVector<T>
{
    private readonly T[] _vector;

    public Vector(int nSize)
    {
        _vector = new T[nSize];
    }

    public T this[int nIndex]
    {
        get { return _vector[nIndex]; }
        set { _vector[nIndex] = value; }
    }

    public IVectorConst<T> AsConst
    {
        get { return new VectorConst<T>(this); }
    }
}


Как мы видим, константный объект (VectorConst) полностью отделился от основного, и downcast из него сделать уже не получится. Отдавая его кому-то, мы можем спать спокойно, будучи уверенными, что наш вектор останется в неизменном виде.

VectorConst не содержит собственной реализации (вся она по-прежнему находится в Vector), он просто переадресует её экземпляру Vector. Но не всё тут так гладко, как хотелось бы… При обращении к VectorConst происходит уже не один вызов, а два, а это уже может быть накладно с т.з. производительности. К тому же, при каждом изменении интерфейса основного объекта придётся дописывать/редактировать методы в IVectorConst и VectorConst, причём при появлении новых подходящих методов компилятор не заставит нас это сделать, мы должны помнить об этом сами, а это лишняя головная боль. Но если эти минусы не столь критичны, то этот подход наверное будет оптимальным. И в особенности, если основной объект уже написан и переписывать его невозможно или нецелесообразно.

4. Реальное отделение константного объекта

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

interface IVectorConst<out T>
{
    T this[int nIndex] { get; }
}

interface IVector<in T>
{
    T this[int nIndex] { set; }
}

struct VectorConst<T> : IVectorConst<T>
{
    private readonly T[] _vector;

    public VectorConst(T[] vector)
    {
        _vector = vector;
    }

    public T this[int nIndex]
    {
        get { return _vector[nIndex]; }
    }
}

struct Vector<T> : IVector<T>
{
    private readonly T[] _vector;
    private readonly VectorConst<T> _reader;

    public Vector(int nSize)
    {
        _reader = new VectorConst<T>(_vector = new T[nSize]);
    }

    public T this[int nIndex]
    {
        set { _vector[nIndex] = value; }
    }

    public VectorConst<T> Reader
    {
        get { return _reader; }
    }

    public static implicit operator VectorConst<T>(Vector<T> vector)
    {
        return vector._reader;
    }
}


Теперь наш VectorConst не только отделён, но и сама реализация вектора разделена на две части. Всё, чем нам пришлось за это заплатить с т.з. производительности, — это инициализация структуры VectorConst копированием ссылки на _vector и дополнительная ссылка в памяти. При передаче VectorConst в метод происходит вызов свойства и такое же копирование. Таким образом, можно сказать, что по производительности это практически равносильно передаче в метод экземпляра T[], но с защитой от изменений (чего мы и добивались). А чтобы при передаче экземпляра Vector в методы, принимающие VectorConst, не вызывать явно лишний раз свойство Reader, можно добавить в Vector оператор преобразования:

public static implicit operator VectorConst<T>(Vector<T> vector)
{
    return vector._reader;
}


Однако при непосредственном использовании объекта без вызова свойства Reader нам не обойтись:

var v = new Vector<int>(5);
v[0] = 0;
Console.WriteLine(v.Reader[0]);


И также нам не обойтись без него, если нам понадобится использовать ковариантность IVectorConst (несмотря на наличие оператора преобразования):

class A
{
}

class B : A
{
}

private static void ReadVector(IVectorConst<A> vector)
{
    ...
}

var vector = new Vector<B>();
ReadVector(vector.Reader);


И это главный и, пожалуй, единственный минус данного подхода: его использование несколько непривычно из-за необходимости в ряде случаев вызывать Reader. Но пока в C# отсутствует const для аргументов, в любом случае придётся чем-то жертвовать.

Многие наверное скажут, что всё это прописные истины. Но возможно для кого-то эта статья и эти несложные шаблоны окажутся полезными.
Tags:
Hubs:
+7
Comments 37
Comments Comments 37

Articles