Привет, Хабр! Об иммутабельных данных немало говориться, но о реализации на С++ найти что-то сложно. И, потому, решил данный восполнить пробел в дебютной статье. Тем более, что в языке D есть, а в С++ – нет. Будет много кода и много букв.


О стиле – служебные классы и метафункции используют имена в стиле STL и boost, пользовательские классы в стиле Qt, с которой я в основном и работаю.


Введение


Что из себя представляют иммутабельные данные? Иммутабельные данные – это наш старый знакомый const, только более строгий. В идеале иммутабельность означает контекстно-независиую неизменяемость ни при каких условиях.


По сути иммутабельные данные должны:


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

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


Как можно реализовать иммутабельные данные в С++?
В С++ у нас есть (сильно упрощенно):


  • значения – объекты фундаментальных типов, экземпляры классов (структур, объединений), перечислений;
  • указатели;
    ссылки;
    массивы.

Функции и void не имеет смысл делать иммутабельными. Ссылки тоже не будем делать иммутабельными, для этого есть const reference_wrapper.



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


Immutable<int> a(1), b(2);

qDebug() << (a + b).value()
         << (a + 1).value()
         << (1 + a).value();

int x[] = { 1, 2, 3, 4, 5 };
Immutable<decltype(x)> arr(x);
qDebug() << arr[0]

Интерфейс


Общий интерфейс прост – всю работу выполняет базовый класс, который выводится из характеристик (traits):


template <typename Type>
class Immutable : public immutable::immutable_impl<Type>::type {
public:
    static_assert(!std::is_same<Type, std::nullptr_t>::value,
                  "nullptr_t cannot used for immutable");
    static_assert(!std::is_volatile<Type>::value,
                  "volatile data cannot used for immutable");
    using ImplType = typename immutable::immutable_impl<Type>;
    using BaseClass = typename ImplType::type;

    using BaseClass::BaseClass;
    using value_type = typename ImplType::value_type;

    constexpr
    Immutable& operator=(const Immutable &) = delete;
};

Запрещая оператор присваивания, мы запрещаем перемещающий оператор присваивания, но не запрещаем перемещающий конструктор.


immutable_impl что-то вроде switch, но по типам (не стал делать такой – слишком усложняет код, да и в простом случае он не особо нужен – ИМХО).


namespace immutable {
    template <typename SrcType>
    struct immutable_impl {
        using Type = std::remove_reference_t<SrcType>;
        using type = std::conditional_t<
            std::is_array<Type>::value,
                array<Type>,
                std::conditional_t <
            std::is_pointer<Type>::value,
                pointer<Type>,
                std::conditional_t <
            is_smart_pointer<Type>::value,
                smart_pointer<Type>,
                immutable_value<Type>
            >
            >
            >;
        using value_type = typename type::value_type;
    };
}

В качестве ограничений явно запретив все операции присваивания (макросы помогают):


template <typename Type, typename RhsType>
constexpr
Immutable<Type>& operator Op=(Immutable<Type> &&, RhsType &&) = delete;

А теперь давайте рассотрим как реализованы отдельные компоненты.


Иммутабельные значения


Под значениями (далее value) понимаются объекты фундаментальных типов, экземпляры классов (структур, объединений), перечислений. Для value у на есть класс, который определяет является ли тип классом, структурой или объединением:


template <typename Type, bool = std::is_class<Type>::value || std::is_union<Type>::value>
class immutable_value;

Если да, то для реализации используется используется CRTP:


template <typename Base>
class immutable_value<Base, true> : private Base
{
public:
    using value_type = Base;
    constexpr
    explicit
    immutable_value(const Base &value)
        : Base(value)
        , m_value(value)
    {
    }
    constexpr
    explicit operator Base() const
    {
        return value();
    }
    constexpr
    Base operator()() const
    {
        return value();
    }
    constexpr
    Base value() const
    {
        return m_value;
    }
private:
    const Base m_value;
};

К сожалению, в С++ пока нет перегрузки оператора .. Хотя, это ожидается в С++ 17 (http://open-std.org/JTC1/SC22/WG21/docs/papers/2016/p0252r0.pdf, http://open-std.org/JTC1/SC22/WG21/docs/papers/2016/p0252r0.pdf, http://www.open-std.org/JTC1/SC22/wg21/docs/papers/2015/p0060r0.html), но вопрос еще открыт, ибо коммитет нашел нестыковки.
Тогда бы можно было просто написать:


    constexpr
    Base operator.() const
    {
        return value();
    }

Но решение по этому вопросу ожидается в марте, поэтому для этих целей пока используем оператор ():


    constexpr
    Base operator()() const
    {
        return value();
    }

Обратите внимание, на конструктор:~~


    constexpr
    explicit
    immutable_value(const Base &value)
        : Base(value)
        , m_value(value)
    {
    }

там инициализируется как immutable_value, так и базовый класс. Это позволяет осмысленно манипулировать с immutable_value через operator (). Например:


QPoint point(100, 500);
Immutable<QPoint> test(point);

test().setX(1000); // не поменяет исходный объект
qDebug() << test().isNull() << test().x() << test().y();

Если же тип является встроенным, то реализация будет один-в-один, за исключением базового класса (можно было бы изъвернуться, чтобы соответствовать DRY, но как-то не хотелось усложнять, тем более, что immutable_value делался после остальных...):


template <typename Type>
class immutable_value<Type, false>
{
public:
    using value_type = Type;
    constexpr
    explicit
    immutable_value(const Type &value)
        : m_value(value)
    {
    }
    constexpr
    explicit operator Type() const
    {
        return value();
    }
    constexpr
    Type operator()() const
    {
        return value();
    }
    //    Base operator . () const
    //    {
    //        return value();
    //    }
    constexpr
    Type value() const
    {
        return m_value;
    }
private:
    const Type m_value;
};

Иммутабельные массивы


Пока вроде бы просто и неинтересно, но теперь примемся за массивы. Надо сделать что-то вроде std::array сохранив естественную семантику работы с массивом, в том числе для работы с STL (что может ослабить иммутабельность).


Особенность релизации заключается в том, что при обращении по индексу к многомерному возвращается массив меньшей размерности, тоже иммутабельный. Тип массива рекурсивно инстанцируется: см. operator[], а конкретные типы для итераторов и т.д выводятся с помощью array_traits.


namespace immutable {
    template <typename Tp>
    class array;

    template <typename ArrayType>
    struct array_traits;

    template <typename Tp, std::size_t Size>
    class array<Tp[Size]>
    {
        typedef       Tp* pointer_type;
        typedef const Tp* const_pointer;
    public:
        using array_type = const Tp[Size];
        using value_type = typename array_traits<array_type>::value_type;
        using size_type  = typename array_traits<array_type>::size_type;

        using iterator               = array_iterator<array_type>;
        using const_iterator         = array_iterator<array_type>;
        using const_reverse_iterator = std::reverse_iterator<const_iterator>;

        constexpr
        explicit
        array(array_type &&array)
            : m_array(std::forward<array_type>(array))
        {
        }

        constexpr
        explicit
        array(array_type &array)
            : m_array(array)
        {
        }
        ~array() = default;

        constexpr
        size_type size() const noexcept
        { return Size; }

        constexpr
        bool empty() const noexcept
        { return size() == 0; }

        constexpr
        const_pointer value() const noexcept
        { return data(); }

        constexpr
        value_type operator[](size_type n) const noexcept
        { return value_type(m_array[n]); } // рекурсивное инстанцирование для типа меньшей размерности

        constexpr
        value_type at(size_type n) const
        { return n < Size ? operator [](n) : out_of_range(); }

        const_iterator begin() const noexcept
        { return const_iterator(m_array.get()); }
        const_iterator end() const noexcept
        { return const_iterator(m_array.get() + Size); }

        const_reverse_iterator rbegin() const noexcept
        { return const_reverse_iterator(end()); }

        const_reverse_iterator rend() const noexcept
        { return const_reverse_iterator(begin()); }

        const_iterator cbegin() const noexcept
        { return const_iterator(data()); }

        const_iterator cend() const noexcept
        { return const_iterator(data() + Size); }

        const_reverse_iterator crbegin() const noexcept
        { return const_reverse_iterator(end()); }

        const_reverse_iterator crend() const noexcept
        { return const_reverse_iterator(begin()); }

        constexpr
        value_type front() const noexcept
        { return *begin(); }

        constexpr
        value_type back() const noexcept
        { return *(end() - 1); }
    private:
        constexpr
        pointer_type data() const noexcept
        { return m_array.get(); }

        [[noreturn]]
        constexpr
        value_type out_of_range() const
        { throw std::out_of_range("array: out of range");}
    private:
        const std::reference_wrapper<array_type> m_array;
    };
}

Для определения типа меньшей размерности используется класс характеристик:


namespace immutable {
    template <typename ArrayType, std::size_t Size>
    struct array_traits<ArrayType[Size]>
    {
        using value_type = std::conditional_t<std::rank<ArrayType[Size]>::value == 1,
                                              ArrayType,
                                              array<ArrayType> // immutable::array
                                             >;
        using size_type  = std::size_t;
    };
}

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


Операторы сравнения очень просты:


Операторы сравнения
template<typename Tp, std::size_t Size>
    inline bool
    operator==(const array<Tp[Size]>& one, const array<Tp[Size]>& two)
    {
        return std::equal(one.begin(), one.end(), two.begin());
    }

    template<typename Tp, std::size_t Size>
    inline bool
    operator!=(const array<Tp[Size]>& one, const array<Tp[Size]>& two)
    {
        return !(one == two);
    }

    template<typename Tp, std::size_t Size>
    inline bool
    operator<(const array<Tp[Size]>& a, const array<Tp[Size]>& b)
    {
        return std::lexicographical_compare(a.begin(), a.end(),
                                            b.begin(), b.end());
    }

    template<typename Tp, std::size_t Size>
    inline bool
    operator>(const array<Tp[Size]>& one, const array<Tp[Size]>& two)
    {
        return two < one;
    }

    template<typename Tp, std::size_t Size>
    inline bool
    operator<=(const array<Tp[Size]>& one, const array<Tp[Size]>& two)
    {
        return !(one > two);
    }

    template<typename Tp, std::size_t Size>
    inline bool
    operator>=(const array<Tp[Size]>& one, const array<Tp[Size]>& two)
    {
        return !(one < two);
    }

Иммутабельный итератор


Для работы с иммутабельным массивом используется иммутабельный итератор array_iterator:


namespace immutable {
    template <typename Tp>
    class array;

    template <typename Array>
    class array_iterator : public std::iterator<std::bidirectional_iterator_tag,
            Array> {
    public:
        using element_type = std::remove_extent_t<Array>;

        using value_type = std::conditional_t<
            std::rank<Array>::value == 1,
            element_type,
            array<element_type>
        >;

        using ptr_to_array_type = const element_type *;

        static_assert(std::is_array<Array>::value,
                      "Substitution error: template argument must be array");

        constexpr
        array_iterator(ptr_to_array_type ptr)
            : m_ptr(ptr)
        {
        }

        constexpr
        value_type operator *() const
        { return value_type(*m_ptr);}

        constexpr
        array_iterator operator++()
        {
            ++m_ptr;
            return *this;
        }

        constexpr
        array_iterator operator--()
        {
            --m_ptr;
            return *this;
        }

        constexpr
        bool operator == (const array_iterator &other) const
        {
            return m_ptr == other.m_ptr;
        }
    private:
        ptr_to_array_type m_ptr;
    };

    template <typename Array>
    inline constexpr
    array_iterator<Array> operator++(array_iterator<Array> &it, int)
    {
        auto res = it;
        ++it;
        return res;
    }

    template <typename Array>
    inline constexpr
    array_iterator<Array> operator--(array_iterator<Array> &it, int)
    {
        auto res = it;
        --it;
        return res;
    }

    template <typename Array>
    inline constexpr
    bool operator != (const array_iterator<Array> &a, const array_iterator<Array> &b)
    {
        return !(a == b);
    }
}
Отделение массивов от указателей сделано сознательно, несмотря на их близкое родство. 
В итоге, получим что-то вроде:

Пример кода с иммутабельным массивом
int x[5] = { 1, 2, 3, 4, 5 };
int y[5] = { 1, 2, 3, 4, 5 };

immutable::array<decltype(x)> a(x);
immutable::array<decltype(y)> b(y);

qDebug() << (a == b);

const char str[] = "abcdef";
immutable::array<decltype(str)> imstr(str);

auto it = imstr.begin();

while(*it)
    qDebug() << *it++;

Для многомерных массивов все тоже самое:


Пример с многомерным иммутабельным массивом
int y[2][3] = {
    { 1, 2, 3 },
    { 4, 5, 6 }
};
int z[2][3] = {
    { 1, 2, 3 },
    { 4, 5, 6 }
};

immutable::array<decltype(y)> b(y);
immutable::array<decltype(z)> c(z);

for(auto row = b.begin(); row != b.end(); ++row)
{
        qDebug() << "(*row)[0]" << (*row)[0];
}

for(int i = 0; i < 2; ++i)
    for(int j = 0; j < 2; ++j)
        qDebug() << b[i][j];

qDebug() << (b == c);

for(auto row = b.begin(); row != b.end(); ++row)
{
    for(auto col = (*row).begin(); col != (*row).end(); ++col)
        qDebug() << *col;
}

Иммутабельные указатели


Попробуем слегка обезопасить указатели. В этом разделе рассмотрим обычные указатели (raw pointers), а далее (сильно далее) рассмотрим smart pointers. Для smart pointers будет использоваться SFINAE.


По реализации immutable::pointer скажу сразу, что pointer не удаляет данные, не считает ссылки, а только обеспечивает неизменяемость объекта. (Если переданный указатель изменен или удален из-вне, то это нарушение контракта, которое средствами языка не отследить (стандартными средствами)). В конце-концов, защититься от умышленного вредительства или игры с адресами невозможно. Указатель должен быть корректно инициализирован.


immutable::pointer может работать с указателями на указатели любой степени ссылочности (скажем так).


Например:


Пример работы с иммутабельными указателями
immutable::pointer<QApplication*> app(&a);
app->quit();

char c = 'A';
char *pc = &c;
char **ppc = &pc;
char ***pppc = &ppc;

immutable::pointer<char***> x(pppc);
qDebug() << ***x;

Кроме вышеперечисленного, immutable::pointer не поддерживает работы со строками в стиле С:


const char *cstr = "test";
immutable::pointer<decltype(str)> p(cstr);

while(*p++)
      qDebug() << *p;

Данный код будет работать не так как ожидается, т.к. immutable::pointer при инкременте возвращает новый immutable::pointer с другим адресом, а в условном выражении будет проверяться результат инкремента, т.е. значение второго символа строки.


Вернемся к реализации. Класс pointer предоставляет общий интерфейс и, в зависимости от того что из себя представляет Tp (указатель на указатель или прото указатель) использует конкретную реализации pointer_impl.


 template <typename Tp>
    class pointer
    {
    public:
        static_assert( std::is_pointer<Tp>::value,
                       "Tp must be pointer");
        static_assert(!std::is_volatile<Tp>::value,
                      "Tp must be nonvolatile pointer");
        static_assert(!std::is_void<std::remove_pointer_t<Tp>>::value,
                      "Tp can't be void pointer");

        typedef Tp                                source_type;
        typedef pointer_impl<Tp>    pointer_type;
        typedef typename pointer_type::value_type value_type;

        constexpr
        explicit
        pointer(Tp ptr)
            : m_ptr(ptr)
        {
        }

        constexpr
        pointer(std::nullptr_t) = delete; // Перегрузка защищает от 0

        ~pointer() = default;

        constexpr
        const pointer_type value() const
        {
            return m_ptr;
        }

     /**
     * @brief operator = необязательное объявление, т.к const *const автоматически
     * запрещает присваивание.
     * При попытке присвоить, компиляторы дают несколько избыточных ошибок,
     * которые могут быть разбросаны по файлам и малоинформативны,
     * а явное описание " = delete" приводит к тому, что диагностируется
     * только одна конкретная ошибка
     */
        pointer& operator=(const pointer&) = delete;

        constexpr /*immutable<value_type>*/
        value_type operator*() const
        {
            return *value();
        }

        constexpr
        const pointer_type operator->() const
        {
            return value();
        }

        // добавим неоднозначности 
        template <typename T>
        constexpr
        operator T() = delete;

        template <typename T>
        constexpr
        operator T() const = delete;
    /**
     * @brief operator [] не реализован сознательно, чтобы не смешивать массивы
     * и указатели.
     *
     * Использование типов-аргументов по-умолчанию помогают компилятору
     * дать более короткое и конкретное сообщение об ошибке
     * (использовании удаленной функции)
     * @return
     */
        template <typename Ret = std::remove_pointer_t<Tp>, typename IndexType = ssize_t>
        constexpr
        Ret operator[](IndexType) const = delete;

        constexpr
        bool operator == (const pointer &other) const
        {
            return value() == other.value();
        }
        constexpr
        bool operator < (const pointer &other) const
        {
            return value() < other.value();
        }
    private:
        const pointer_type m_ptr;
    };

Суть следующая: был тип T , а для его хранения/представления используется (шаблонно-рекурсивно) реализация pointer_impl<T , true>, что можно изобразить так:


pointer_impl<T***, true>{
      pointer_impl<T**, true>
      {
          pointer_impl<T*, false>
          {
              const T *const
          }
      }
}

Итого, получается: const T const const *const.


Для простого указателя (который не указывает на другой указатель) реализация следующая:


    template <typename Type>
    class pointer_impl<Type, false>
    {
    public:
        typedef std::remove_pointer_t<Type> source_type;
        typedef source_type *const          pointer_type;
        typedef source_type                 value_type;

        constexpr
        pointer_impl(Type value)
            : m_value(value)
        {
        }

        constexpr
        value_type operator*() const noexcept
        {
            return *m_value;
            //     * для обычных указателей
        }

        constexpr
        bool operator == (const pointer_impl &other) const noexcept
        {
            return m_value == other;
        }

        constexpr
        bool operator < (const pointer_impl &other) const noexcept
        {
            return m_value < other;
        }

        constexpr
        const pointer_type operator->() const noexcept
        {
            using class_type = std::remove_pointer_t<pointer_type>;

            static_assert(std::is_class<class_type>::value || std::is_union<class_type>::value ,
                          "-> used only for class, union or struct");
            return m_value;
        }

    private:
        const pointer_type m_value;
    };

Для вложенных указателей (указатели на указатели):


    template <typename Type>
    class pointer_impl<Type, true>
    {
    public:
        typedef std::remove_pointer_t<Type>             source_type;
        typedef pointer_impl<source_type> pointer_type;
        typedef pointer_impl<source_type> value_type;

        constexpr
        /* implicit */
        pointer_impl(Type value)
            : m_value(*value)
        {   //        /\ remove pointer
        }

        constexpr
        bool operator == (const pointer_impl &other) const
        {
            return m_value == other; // рекурсивное инстанцирование
        }

        constexpr
        bool operator < (const pointer_impl &other) const
        {
            return m_value < other; // рекурсивное инстанцирование
        }

        constexpr
        value_type operator*() const
        {
            return value_type(m_value); // рекурсивное инстанцирование
        }

        constexpr
        const pointer_type operator->() const
        {
            return m_value;
        }

    private:
        const pointer_type m_value;
    };

Что не надо делать!

Для следующих видов указателей особого смысла не стоит делать специализации:


  • указатель на массив (*)[];
  • указатель на функцию(*)(Args… [...]);
  • указатель на переменную класса, Class:: весьма специфичная вещь, нужна при "колдовстве" с классом, нужно связывать с объектом;
    -указатель на метод класса (Class::
    )(Args… [...]) [const][volatile].

Иммутабельные smart pointers


Как определить что перед нами smart pointer? Smart pointers реализуют операторы * и ->. Чтобы определить их наличие воспользуемся SFINAE (реализацию SFINAE рассмотрим позже):


namespace immutable
{
    // is_base_of<_Class, _Tp>
    template <typename Tp>
    class is_smart_pointer {
        DECLARE_SFINAE_TESTER(unref, T, t, t.operator*());
        DECLARE_SFINAE_TESTER(raw,   T, t, t.operator->());
    public:
        static const bool value = std::is_class<Tp>::value
                                && GET_SFINAE_RESULT(unref, Tp)
                                && GET_SFINAE_RESULT(raw, Tp);
    };
}

Скажу сразу, что через operator ->, увы, используя косвенное обращение, можно нарушить иммутабельность, особенно если в классе есть mutable данные. Кроме того константность возвращаемого значения может быть снята, как компилятором (при выводе типа), так и пользователем.


Реализация – здесь все просто:


namespace immutable
{
    template <typename Type>
    class smart_pointer {
    public:
        constexpr
        explicit
        smart_pointer(Type &&ptr) noexcept
            : m_value(std::forward<Type>(ptr))
        {

        }
        constexpr
        explicit
        smart_pointer(const Type &ptr)
            : m_value(ptr)
        {

        }

        constexpr
        const auto operator->() const
        {
            const auto res = value().operator->();
            return immutable::pointer<decltype(res)>(res);// in C++17 immutable::pointer(res);
        }

        constexpr
        const auto operator*() const
        {
            return value().operator*();
        }

        constexpr
        const Type value() const
        {
            return m_value;
        }
    private:
        const Type m_value;
    };
}

SFINAE


Что это такое и с чем его едят лишний раз объяснять не надо. С помощью SFINAE можно определить наличие в классе методов, типов-членов и т.д, даже наличие перегруженных функций (если задать в выражении testexpr вызов нужной функции с необходимыми параметрами). arg может быть пустым и не участвовать в testexpr. Здесь используется SFINAE с типами и SFINAE с выражениями:


#define DECLARE_SFINAE_BASE(Name, ArgType, arg, testexpr)       typedef char SuccessType;                                   typedef struct { SuccessType a[2]; } FailureType;           template <typename ArgType>                                 static decltype(auto) test(ArgType &&arg)                           -> decltype(testexpr, SuccessType());               static FailureType test(...);

#define DECLARE_SFINAE_TESTER(Name, ArgType, arg, testexpr) struct Name {                                                   DECLARE_SFINAE_BASE(Name, ArgType, arg, testexpr)       };

#define GET_SFINAE_RESULT(Name, Type) (sizeof(Name::test(std::declval<Type>())) ==                                        sizeof(typename Name::SuccessType))

И еще: перегрузку можно разрешить (найти нужную перегруженную функцию) если сигнатуры совпадают, но отличаются квалификатором const [ volatile ] или volatile совместно с SFINAE в три фазы:


1) SFINAE — если есть, то ОК
2) SFINAE + QNonConstOverload, если не получилось, то
3) SFINAE + QConstOverload


В исходниках Qt можно найти интересную и полезную вещь:


Разрешение перегрузки с const
    template <typename... Args>
    struct QNonConstOverload
    {
        template <typename R, typename T>
        Q_DECL_CONSTEXPR auto operator()(R (T::*ptr)(Args...)) const Q_DECL_NOTHROW -> decltype(ptr)
        { return ptr; }

        template <typename R, typename T>
        static Q_DECL_CONSTEXPR auto of(R (T::*ptr)(Args...)) Q_DECL_NOTHROW -> decltype(ptr)
        { return ptr; }
    };

    template <typename... Args>
    struct QConstOverload
    {
        template <typename R, typename T>
        Q_DECL_CONSTEXPR auto operator()(R (T::*ptr)(Args...) const) const Q_DECL_NOTHROW -> decltype(ptr)
        { return ptr; }

       template <typename R, typename T>
        static Q_DECL_CONSTEXPR auto of(R (T::*ptr)(Args...) const) Q_DECL_NOTHROW -> decltype(ptr)
        { return ptr; }
    };

    template <typename... Args>
    struct QOverload : QConstOverload<Args...>, QNonConstOverload<Args...>
    {
        using QConstOverload<Args...>::of;
        using QConstOverload<Args...>::operator();
        using QNonConstOverload<Args...>::of;
        using QNonConstOverload<Args...>::operator();

        template <typename R>
        Q_DECL_CONSTEXPR auto operator()(R (*ptr)(Args...)) const Q_DECL_NOTHROW -> decltype(ptr)
        { return ptr; }

        template <typename R>
        static Q_DECL_CONSTEXPR auto of(R (*ptr)(Args...)) Q_DECL_NOTHROW -> decltype(ptr)
        { return ptr; }
    };

Итог


Попробуем что получилось:


QPoint point(100, 500);
Immutable<QPoint> test(point);

test().setX(1000); // не поменяет исходный объект
qDebug() << test().isNull() << test().x() << test().y();

int x[] = { 1, 2, 3, 4, 5 };
Immutable<decltype(x)> arr(x);
qDebug() << arr[0];

Операторы


Давате вспомним про операторы! Например, добавим поддержку оператора сложения:
Сначала реализуем оператор сложения вида Immutable<Type> + Type:


template <typename Type>
inline constexpr
Immutable<Type> operator+(const Immutable<Type> &a, Type &&b)
{
    return Immutable<Type>(a.value() + b);
}

В С++17 вместо


 return Immutable<Type>(a.value() + b); 

можно записать


return Immutable(a.value() + b);

Т.к. оператор + коммутативен, то Type + Immutable<Type> можно реализовать в виде:


template <typename Type>
inline constexpr
Immutable<Type> operator+(Type &&a, const Immutable<Type> &b)
{
    return b + std::forward<Type>(a);
}

И снова, через первую форму реализуем Immutable<Type> + Immutable<Type>:


template <typename Type>
inline constexpr
Immutable<Type> operator+(const Immutable<Type> &a, const Immutable<Type> &b)
{
    return a + b.value();
}

Теперь можем работать:


Immutable<int> a(1), b(2);

qDebug() << (a + b).value()
         << (a + 1).value()
         << (1 + a).value();

Аналогично можно определить остальные операции. Вот только не надо перегружать операторы получения адреса, &&, ||! Унарные +, -, !, ~ могут пригодиться… Эти операции наследуются: (), [], ->, ->, (унарный).


Операторы сравнения должны возвращать значения булевского типа:


Операторы сравнения
template <typename Type>
inline constexpr
bool operator==(const Immutable<Type> &a, const Immutable<Type> &b)
{
    return a.value() == b.value();
}

template <typename Type>
inline constexpr
bool operator!=(const Immutable<Type> &a, const Immutable<Type> &b)
{
    return !(a == b);
}

template <typename Type>
inline constexpr
bool operator>(const Immutable<Type> &a, const Immutable<Type> &b)
{
    return a.value() > b.value();
}

template <typename Type>
inline constexpr
bool operator<(const Immutable<Type> &a, const Immutable<Type> &b)
{
    return b < a;
}

template <typename Type>
inline constexpr
bool operator>=(const Immutable<Type> &a, const Immutable<Type> &b)
{
    return !(a < b);
}

template <typename Type>
inline constexpr
bool operator<=(const Immutable<Type> &a, const Immutable<Type> &b)
{
    return !(b < a);
}
Поделиться с друзьями
-->

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


  1. bfDeveloper
    20.02.2017 13:13
    +3

    Спасибо за статью, радикальный подход!
    Проясните, пожалуйста, следующий момент:

    test().setX(1000); // не поменяет исходный объект
    

    Что такое setX на неизменяемом объекте? Какой объект поменяется? Я ожидал увидеть здесь ошибку компиляции, так как не константные методы не должны работать на immutable.


    1. Antervis
      20.02.2017 13:31
      +1

      создаётся копия объекта, для копии вызывается setX, копия выходит из контекста и удаляется.


      1. bfDeveloper
        20.02.2017 14:03
        +2

        А зачем? Может стоит возвращать константную ссылку, чтобы подобного не происходило? Или это замысел такой?


        1. Antervis
          20.02.2017 14:06

          Могу предположить, что это чтобы легче было использовать парадигмы ФП в с++. Зачем? Спросите автора, я не знаю ;)


          1. ixjxk
            20.02.2017 20:05

            Где нужна иммутабельность? Например, в паралелльном программировании — иммутабельность уменьшает количество побочных эффектов. ФП в С++ — boost, в Qt активно используется в фьючерсах, там есть известная концепция: ReduceMap и тд.


            1. iCpu
              20.02.2017 20:39

              Перед тем, как заявлять иммутабельность, стоило бы посмотреть, в какой код преобразуется ваш шаблон, и какие машинные коды из него получатся. \Fa в студии и -S в мингве.

              И не забывайте, 99% не заморачиваются const_cast. В лучшем случае сразу reinterpret_sast, но чаще просто (T*).


        1. ixjxk
          20.02.2017 20:05

          помните про const_cast


          1. DistortNeo Автор
            20.02.2017 20:33

            помните про reinterpret_cast, который вертел ваши объекты как ему хочется


      1. Deosis
        21.02.2017 06:58

        Это же темная комната с детскими грабельками.


    1. ixjxk
      20.02.2017 20:25
      -1

      Смысл в сохранении естественной семантики работы с объектом. setX может что-то вернуть и это что-то может использоваться далее. В С++ пока нету перегрузки оператора. (пока нету, может быть в середине марта будет решение), который позволит реализовывать умные ссылки, поможет делать защитные заместители и декораторы.


  1. 0serg
    20.02.2017 13:39
    +4

    Я честно говоря не понял в чем преимущества

    Immutable<int> a(1);
    

    перед банальным (и отлично работающим)

    const int a(1);
    


    1. mayorovp
      20.02.2017 14:23
      +1

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


      const просто запрещает подпрограмме изменять объект — но не дает никаких гарантий. Immutable дает гарантию подпрограмме что снаружи объект также никто не изменит.


      1. iCpu
        20.02.2017 15:13
        -1

        Чукча не дуракКомпилятор оптимизирующий, статические константные величины просто будут подставляться в качестве значения. В худшем случае, поместит в секцию .data. А от прямой инъекции в твою память тебя практически ничто не спасёт.


        1. mayorovp
          20.02.2017 15:32

          При чем тут инъекция в память?


          1. DistortNeo Автор
            20.02.2017 15:36

            Если программист захочет изменить неизменяемое значение, он это сделает, C++ его в этом не ограничивает. Единственный способ не допустить этого — передавать данные в функции по значению.


            1. mayorovp
              20.02.2017 15:48

              Зачем менять значение, которое декларировано как неизменяемое?


              1. Antervis
                20.02.2017 15:54

                так а если незачем, то «зачем платить больше» и копировать то, что не собираешься изменять?


                1. mayorovp
                  20.02.2017 15:57

                  А кто говорил о копировании? В том-то и смысл, что там где обычную структуру надо копировать — на Immutable-версию можно передавать ссылку.


                1. DistortNeo Автор
                  20.02.2017 16:35

                  А теперь посмотрите внимательно на приведённый в публикации код, а именно на operator(), который возвращает копию объекта.


                  Зачем передавать ссылку на Immutable, а затем при каждом обращении к нему делать копию объекта, если копию можно сделать только один раз, передавая аргументы в функцию по значению? Just KISS.


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


                  1. mayorovp
                    20.02.2017 16:41

                    Да там вообще много глупостей. Я отвечал на вопрос "зачем", а не доказывал идеальность конкретной реализации.


                1. ixjxk
                  20.02.2017 20:09
                  -4

                  еще раз: есть такая нехорошая вешь как const_cast. Посмотрите «Язык программирования D» и перечитайте введение


              1. DistortNeo Автор
                20.02.2017 16:18

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


          1. iCpu
            20.02.2017 20:25
            -1

            Во-первых, любая попытка изменения константы этапа компиляции — UB. Тут и говорить не о чем.
            Во-вторых, есть серьёзные сомнения, что компилятор сможет правильно переварить всю эту мешанину на серьёзных классах. Что получится в итоге, ванговать не имеет смысла
            В-третьих, я не вижу T* operator&() = delete;, так что ничто не помешает навернуть константность через (void*) (Кроме стандарта, но разве он написан не для того, чтобы мы его нарушали?)
            В-четвёртых, даже если выполнить предыдущий пункт, всё ещё можно прогибать на синонимичный шаблон без const через reinterpret_cast.
            В-пятых, для меня до сих пор не ясно, каким местом это безопаснее и нагляднее, чем const& или const*const. Ну да, выглядит дишнее. Ну да, типа нагляднее. И? Мы пришли на плюсах программировать или делать из них очередное manageбожество?! Быть может, не стоит городить то, что всё равно не сработает?


            1. ixjxk
              20.02.2017 20:45
              -1

              1) иммутабельные данные могут обрабатываться в разных потоках без проблем, за счет меньшего числа побочных эффектов.
              2) может. Такое устроит (это из будущей статьи):


              QVector<Immutable<int>> imm = {
                         Immutable<int>(1),
                         Immutable<int>(2),
                         Immutable<int>(3),
                         Immutable<int>(4),
                         Immutable<int>(5),
                     };
              
              Stream<decltype(imm)> t(imm);
              qDebug() << t.map(static_cast<QString(*)(int, int)>(&QString::number), 2)
                          .filter(std::bind(
                                      std::logical_not<>(),
                                      std::bind(QString::isEmpty, std::placeholders::_1))
                                  )
                          .map(&QString::length)
                          .filter(std::greater<>(), 1)
                          .filter(std::bind(
                                      std::logical_and<>(),
                                      std::bind(std::greater_equal<>(), std::placeholders::_1, 1),
                                      std::bind(std::less_equal<>(),    std::placeholders::_1, 100)
                                      )
                                  )
                          .reduce(std::multiplies<>(), 1);

              3) в конце статьи упомянуто, что такое не надо делать;
              4) манипулируя с адресом можно сделать все что угодно. Даже в Java через механизм рефлексии можно напакостить;
              5) меньше побочных эффектов, т.к. работаете с копией.


              1. iCpu
                20.02.2017 20:58

                2) А можно мне не факт компиляции, а сравнение сгенерированного ассемблерного кода для вашей версии и классического подхода?
                5) Спасаясь от мутабельности вы нарываетесь на иммутабельность. А я тем временем всё равно не знаю, чем ваша мешанина лучше, чем

                int a = 5;
                const int& b = a;
                

                http://ideone.com/sOcMvB
                Всё, дошло.


                1. iCpu
                  20.02.2017 21:07

                  5) Ответ самому себе, Immutable сохраняет своё состояние, но, при этом, плодит уймы своих изменённых копий, которые, конечно, могли бы быть выоптимизированны компилятором, но даже если это и произойдёт, на времени построения и перепостроения это скажется фатальным образом.
                  В целом подход имеет право быть, но я бы не заморачивался константностью внутри класса, и просто бы перегрузил все операторы для Immutable поведения, оставив возможность работать с хранимым значением напрямую.


                  1. ixjxk
                    21.02.2017 00:34

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


                    1. Antervis
                      21.02.2017 06:42

                      Зачем его представлять, если вы предлагаете постоянно копировать объекты?


              1. Antervis
                21.02.2017 06:40

                откуда взяться этим побочным эффектам? У вас есть 6 базовых способа передать значение в функцию (=, &, const&, *, const*, &&), их все же не просто так придумали, а для того, чтобы функция работала с данными простым, понятным и максимально эффективным способом


      1. NickViz
        20.02.2017 20:11

        «const просто запрещает подпрограмме изменять объект — но не дает никаких гарантий. Immutable дает гарантию подпрограмме что снаружи объект также никто не изменит.»

        Вы не могли бы пояснить свою мысль? Кто может снаружи изменить объект? Другой тред?


        1. ixjxk
          20.02.2017 20:19
          -2

          Смотрите, вы передаете в другую функцию: внутри можно изменить с помощью const_cast, игры с адресами и т.д. При работе с Immutable вы работаете с копией, а не с оригиналом. Прочитайте главу 8.1 «Язык программирования D», автор А. Александреску.
          Иммутабельные данные в параллельном программировании в разы снижают количество ошибок, за счет меньшего числа побочных эффектов.


          1. 0serg
            20.02.2017 20:46
            +2

            Не лучше ли просто писать нормальные программы где не используют подобные хаки? Тогда компилятор вежливо предупредит Вас что Вы пытаетесь передать иммутабельный объект в функцию которая может его изменить, а Вы всегда можете поставить в этом месте создание копии, если подобное решение допустимо. Да, это порождает необходимость расстановки кучи const-ов везде где это нужно. Но зато в итоге и получается намного более надежный и понятный код. Ваше решение пытается создавать копии автоматически, но мне честно говоря кажется что это плохая идея. Копий будет создано больше чем нужно, некоторые ошибки в норме выявимые еще на этапе компиляции очень странно проявят себя в рантайме, да и в целом довольно громоздко.


          1. DistortNeo Автор
            20.02.2017 20:54
            +2

            При работе с Immutable вы работаете с копией, а не с оригиналом

            Так чем это лучше простой передачи по значению?


            Главное отличие immutable от const: преобразование &T -> const &T можно сделать неявно, а &T -> immutable &T — нет. Собственно, автор и попытался сделать враппер для эмуляции функционала D.


        1. mayorovp
          20.02.2017 20:38
          +1

          Обратный вызов. Или сама функция:


          void foo(const int& a, int& b) {
            b = 42;
            std::cout << a << std::endl;
          }
          
          //...
          
          int a = 5;
          foo(a, a); // Сюрприз!


          1. ixjxk
            20.02.2017 20:47

            Не понял к чему это: вы меняете неконстантную ссылку


            1. mayorovp
              20.02.2017 20:48
              +3

              Внутри foo ссылка a — константная. Но это не помешало ей внезапно измениться в процессе выполнения foo.


              1. 0serg
                20.02.2017 21:57
                -1

                Вы неверно понимаете модификатор const. В Вашем примере есть не константная переменная a и функция foo которая обещает не менять первый из аргументов получаемый по ссылке. Если Вы хотите сделать «иммутабельный объект», то пишете

                const int a = 5;
                foo(a, a); // Действительно сюрприз, причем от компилятора!


                Модификатор const у функции лишь говорит что иммутабельный объект может использоваться в качестве первого аргумента функции. Он не защищает (и не должен защищать) от внешнего по отношению к функции изменению объекта (в данном примере таковым является вызов функции, а не ее тело).


                1. mayorovp
                  20.02.2017 22:03

                  А я что говорю?


                  1. 0serg
                    20.02.2017 22:36

                    Если Вам нужен иммутабельный объект, то Вы объявляете этот объект иммутабельным. Const int как в моём примере — и всё, дальше Вы получили все гарантии.
                    Объявление же аргумента функции const не делает лежащий за ним объект иммутабельным.


                    1. mayorovp
                      20.02.2017 22:41

                      А что делать если функция должна принять иммутабельный параметр по ссылке-то?


                      1. 0serg
                        20.02.2017 22:59
                        -1

                        Функция никак не может гарантировать иммутабельность объекта который она принимает, да и не должна, вообще говоря. Иммутабельность тех или иных объектов — часть архитектуры приложения. К примеру если какой-то объект обязательно должен быть иммутабельным, то мы можем запретить его прямое создание заставив пользователя пользоваться фабрикой или встроенными функциями-конструкторами которые будут возвращать константные ссылки. Если же вдруг в приложении творится бардак в силу чего есть некий подозрительный объект который непонятно кто и когда его может изменить и этот объект хочется «превратить» в иммутабельный, то достаточно сделать ОДНУ его копию, помеченную как иммутабельную вместо того чтобы городить огород с обертками

                        void foo(const int& a, int& b) {
                          const immutable_a(a); // черт его знает что нам передали
                          b = 42;
                          std::cout << immutable_a << std::endl;
                        }
                        
                        //...
                        
                        int a = 5;
                        foo(a, a); // Работает!
                        


                        1. mayorovp
                          20.02.2017 23:00

                          А если требуется избежать копирования?..


                          1. 0serg
                            20.02.2017 23:25

                            Иммутабельность нужно обеспечить лишь один раз. При грамотно спроектированном приложении у любого иммутабельного объекта жизнь четко делится на две половины: инициализация где объект не расшарен и иммутабельное состояние где объект собственно и используется по назначению. Нам достаточно проследить чтобы интерфейс обеспечивающий передачу объекта из первой половины во вторую допускал передачу объекта только в виде константных ссылок. Как правило никаких сложностей с этим не возникает. При таком подходе ничего лишний раз никогда не копируется — в отличие от подхода автора, к слову, в котором копии легким движением руки порождаются на каждый чих. У нас на подобных иммутабельных объектах все приложение обрабатывающее данные в реальном времени построено, поверьте: оно там все отлично работает, const в C++ умные люди придумали. Если же в приложении бардак и требуется зачем-то постоянно превращать не-immutable состояние в immutable, то таки да, придется плодить копии. Но собственно, immutable<> шаблон в этом ничего не меняет.


                            1. mayorovp
                              20.02.2017 23:47
                              +1

                              Вы все еще подходите со стороны вызывающего кода. Да, там достаточно const.


                              Но для вызываемого кода константная ссылка не гарантирует иммутабельность.


                              1. 0serg
                                21.02.2017 00:01
                                -2

                                Иммутабельность — свойство объекта, а не функции которая с ним работает.
                                Объект предоставляет вызывающий код, ему и обеспечивать его иммутабельность.
                                Собственно шаблон автора ничего в этом подходе не меняет. От слова «совсем». Экземпляр immutable<> будет создавать вызывающий код и при этом переход от «обычного» объекта к immutable потребует создания копии


                                1. ixjxk
                                  21.02.2017 00:46

                                  Мои, самые вдумчивые читатели, верным путем идете, товарищи, вот пища для ума, основанная на Ваших примерах:


                                  void foo(const Immutable<int> &a, Immutable<int> &b)
                                  {
                                      a = 42;
                                      b = 42;
                                      a = b;
                                      b = a;
                                  
                                      const_cast<Immutable<int>&>(a) = 100;
                                      a = Immutable<int>(500);
                                      b = Immutable<int>(500);
                                      a = std::move(Immutable<int>(500));
                                      b = std::move(Immutable<int>(500));
                                  }

                                  Каждое присваивание даст ошибку компиляции. Шаблон Immutable<> дает еще один уровень защиты.
                                  Можно один раз объект обернуть в Immutable<>, а дальше использовать по ссылке и никаких лишних копирований.


                                  1. mayorovp
                                    21.02.2017 06:33
                                    +1

                                    Лишние копирования у вас будут при каждом вызове оператора (). И еще в конструкторе.


                                  1. 0serg
                                    21.02.2017 09:05
                                    -1

                                    Уважаемый ixjxk, я прекрасно понимаю Вашу идею «дополнительного уровня защиты». Вы заставляете вызывающий код создавать immutable-копию каждый раз когда происходит переход от «обычного» кода к коду работающему с immutable-данными, но если весь код работающий дальше использует Ваш шаблон, то дополнительного копирования после этого первого не происходит. Там есть определенные косяки с сеттерами (по хорошему весь класс следовало бы банально сделать эквивалентом const X&) но в целом он работает. Это все понятно и меня честно говоря уже начали раздражать люди которые повторяют одни и те же вещи «человеку не понявшему идеи». Да все я прекрасно понял, спасибо. Для меня эта тема весьма актуальна поскольку, повторю, у меня есть приложение которое обрабатывает гигабайты данных в реальном времени в двадцать потоков которые их шарят между собой. И Вы знаете, за 3 года разработки в команде из 20 человек случаев когда const-защиты не хватило бы не было ни одного. Все они отлавливались компилятором, очень удобно было, причем код кое-где пришлось переписать существенно. Вот с mutable-данными проблемы были и мы, кстати, придумали как довольно неплохо защитить от случайных ошибок и их (правда, уже не на уровне компиляции, а в реал-тайме).

                                    Так вот, возвращаясь к нашим баранам: на основании своего опыта я пытаюсь сказать одну простую, в общем-то, вещь. Функции (да и объекты) не существуют сами по себе, «в вакууме». Они являются частями приложения. И у этого приложения должна быть структура. Это включает в себя внятное понимание того какие данные есть в приложении, как их организовать наиболее удобным образом в объекты и как происходит обработка этих объектов. И уже под эту структуру пишется собственно код. Вопросы времени жизни объекта и того шарится ли объект между разными потоками или нет естественным образом являются частью этой структуры и как правило естественным образом решаются в ее рамках. И тогда описанные выше проблемы которые Вы пытаетесь решать, на уровне функций уже просто не возникают. И оказывается возможным не копировать объекты вообще (у Вас они копируются минимум один раз).

                                    C++ в этом отношении довольно специфичный язык. Он очень сильно заточен на то чтобы работать с приложениями наделенными подобной структурой. Если же подобной структуры в приложении нет то… в плюсах есть сто тысяч и один способ выстрелить себе в ногу и те кто пытаются халтурить с плюсовым кодом быстро познают эту истину. А затем начинается попытка «исправить плохой C++ шаблонами» дописав туда функциональность которая один-два подобных способа перекрывает. Мне это представляется плохой идеей, блуждая вслепую Вы наступите не на одни грабли так на другие. Для написания подобного «бесструктурного» кода лучше подходит C#, а не C++ и холивар на тему GC тому ярким свидетельством: «умные указатели» в плюсах как и все остальное сильно увязано на наличие у приложения структуры.


  1. 0serg
    21.02.2017 00:01

    (не в ту ветку)


  1. monah_tuk
    22.02.2017 17:54
    +3

    В С++17 вместо return Immutable(a.value() + b); можно записать return Immutable(a.value() + b);

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


    1. ixjxk
      22.02.2017 19:09

      Спасибо, пофиксил. Ставлю +


  1. 4eyes
    22.02.2017 19:09

    Не могли бы вы рассказать, зачем здесь m_value?

    template <typename Base>
    class immutable_value<Base, true> : private Base
    {
    public:
        using value_type = Base;
        constexpr explicit immutable_value(const Base &value)
            : Base(value)
            , m_value(value)
        {
        }
    
        // ...
        constexpr Base value() const
        {
            return m_value;
        }
    


    Мне кажется, что так было бы и проще, и на одну копию меньше:
    template <typename Base>
    class immutable_value<Base, true> : private Base
    {
    public:
        using value_type = Base;
        constexpr explicit immutable_value(const Base &value)
            : Base(value)
        {
        }
    
        // ...
        constexpr Base value() const
        {
            return static_cast<Base>(*this);
        }
    


    1. mayorovp
      22.02.2017 19:22

      Только все-таки *static_cast<Base const*>(this).


      1. 4eyes
        22.02.2017 19:32

        Если причесать, то можно так:

        return *this;