Что не так с ссылками в С++

в 11:44, , рубрики: c++, null, С++, ссылки, указатели, метки: , , ,

Disclaimer: На данный момент я не располагаю достаточным опытом работы с С++11, поэтому все рассуждения следует рассматривать исключительно в контексте С++03, однако буду рад обсудить в комментариях взаимодействие нововведений С++ с рассмотренными в статье проблемами.

Ссылки в C++ появились чтобы удовлетворить синтаксические потребности механизма перегрузки операторов. В чистом С нет ссылочных типов, вместо этого есть понятие lvalue, которое описывается размытой формулировкой «то, что может стоять слева от оператора присваивания».

// Чистый C
int a;
int foo(int);
a = 7; // тип a - int
5 = 7; // и тип 5 - int
foo(42) = 7; // и тип foo(42) - тоже int 

В этом маленьком примере три выражения: переменная a, литерал «5» и вызов функции foo(42) — имеют одинаковый тип — int, но только переменная является lvalue и может стоять слева от оператора присваивания.

С точки зрения С программиста, выражение «foo(42) = 7;» лишено здравого смысла и не должно компилироваться, однако с появлением перегрузки операторов, возникла потребность именно в таких выражениях.

В С++ операция обращения к элементу массива трактуется как вызов функции-члена operator[](size_t n). И должна вернуть нечто, что может стоять слева от оператора присваивания. И нужен тип, который бы позволил описать это. Так появились ссылки.

Ссылка, как и указатель, хранит в себе адрес объекта в памяти, однако синтаксически является разыменованным указателем. Это позволяет решить поставленные выше задачи, но создает новые проблемы.

Синтаксис языка не позволяет различить целевой объект и саму ссылку — все операции над ссылкой на самом деле являются операциями над объектом. Как следствие этого:
1. Ссылку нельзя переназначить на другой объект.
2. Нельзя сравнить адрес, содержащийся в ссылке, с адресом другого объекта или NULL.

Из этих свойств в свою очередь следуют другие ограничения:
3. Ссылка должна быть инициализирована при создании (поскольку инициализировать ее позже не возможно).
4. Ссылка не может содержать нулевой адрес (поскольку проверить и обработать это невозможно).

Последние два свойства являются большими достоинствами ссылок. Мне часто встречаются рекомендации отказаться от указателей в пользу ссылок ради этих двух свойств (например этот coding guide, еще один, обсуждение на StackOverflow и, увы, coding guide, по которому сейчас работает ваш покорный слуга).

Однако есть и противоположное мнение (например, указатели предпочитают инженеры Google и Trolltech), поскольку противоречие между синтаксисом и семантикой ссылок создает много проблем.

Использование ссылок для передачи выходных аргументов функции, делает факт «выходности» крайне не очевидным при чтении вызова функции:

    color.getHsv(&h, &s, &v); // очевидно что мы даем методу getHsv() право менять переменные h,s,v
    color.getHsv(h, s, v); // об использовании h,s,v ничего сказать нельзя

Использование константных ссылок стало стандартом де-факто для оптимизированной передачи объекта по значению. Видя запись «const SomeClass& arg» я в последнюю очередь подумаю о том, что в данном случае передается ссылка на экземпляр класса SomeClass без права его изменения, и важно что функция должна работать именно с этим экземпляром. Я подумаю что здесь передается значение типа SomeClass. А раз передается значение, то я могу передавать в эту функцию любой объект этого класса, содержащий это значение.

Ссылки вызывают определенные затруднения при мета-программировании, порождая костыли вроде Boost.Ref.

Ссылки не могут быть элементами STL-ных контейнеров. Для класса, в котором есть поле-ссылка невозможно реализовать оператор присваивания (не прибегая к грязным хакам). Поэтому объекты таких классов тоже не могут быть элементами контейнеров.

По мотивам недавно пойманного бага:

template<class T>
T foo(T x) { ... }

template<class T>
class Bar
{
public:
    static T baz(T x)
    {
        foo(x);
    }
};

std::string str = Bar<std::string>::baz(getTitle()); // ОК
ColorDescriptor& desc = Bar<ColorDescriptor&>::baz(getColorDescriptor()); // Бум!

А вот еще интересный пример:

template<class T>
class SizeOfTest
{
public:
    static bool sizeOfIsOK()
    {
        return sizeof(SizeOfTest<T>) >= sizeof(T);    
    }
private:
    T m_data;
};

struct BigData { char d[1000]; };

assert(SizeOfTest<int>::sizeOfIsOK()); // ОК
assert(SizeOfTest<BigData>::sizeOfIsOK()); // ОК
assert(SizeOfTest<BigData&>::sizeOfIsOK()); // Бум!

Так что ссылки не могут служить полноценной заменой указателям в С++. Не для этого они создавались.

Но с другой стороны, виден спрос на «чистые» указатели — указатели, для которых система типов гарантирует, что они инициализированы и не NULL. И что самое интересное — свойства (3,4) по своей природе не конфликтуют с семантикой указателя. Проблема создается только ограниченным выбором средств доступных в С++.

Давайте немного помечтаем и освободимся от рамок обратной совместимости.

Будь моя воля, я бы сделал свойства (3,4) свойствами самих указателей, сохранив их семантику. Т. е.

int a = 5, b = 5;
int* p1; // Ошибка
int* p2 = null;  // Ошибка
int* p3 = &a;
int* p4 = &b;
assert(p3 != p4);
assert(*p3 == *p4);
p3 = &b;
assert(p3 == p4);
int * p5 = std::min(p3, p4);
int * p6 = new int(5); // new либо вернет корректный указатель, либо сгенерирует исключение
if (p5) { ... } // Ошибка - приведение к bool не имеет смысла для таких указателей.

Но как же быть с NULL? Ведь иногда все же нужна семантика опциональности. Вместо того, чтобы возвращаться к обнуляемым указателям, можно сделать лучше — реализовать опциональность ортогонально семантике указателей:

int a = 5;
int? b = 5; // Установленный опциональный int
int? c = null; // Сброшенный опциональный int
assert(a == b);
assert(b != c);
int* p0 = &a;
int*? p1 = &a;
int*? p2 = null;
int*? p3 = &b; // Ошибка
int?* p4 = &b;
int?*? p5 = null;
p5 = p4;
p4 = p5; // Ошибка
*p0 = 7;
*p1 = 7; // Ошибка: p1 - это НЕ указатель
if(p1 != null)
{
    *?p1 = 7;
}
p0 = ?p1;

А можно ли обойтись без ссылок вообще? Давайте попробуем.

Начнем с передачи аргументов по константой ссылке. Такой способ передачи является оптимизированным вариантом передачи аргумента по значению. Для одних типов эта оптимизация имеет смысл, а для других — нет.

Чтобы принять правильно решение относительно этой оптимизации надо учесть:

  • Стоимость выделения памяти под копию объекта
  • Стоимость конструктора копирования и деструктора
  • Стоимость разыменования ссылки внутри функции
  • Соглашение о вызове для конкретной функции — решение может быть разным при использовании регистров и стека
  • Возможный выигрыш от оптимизаций, которые компилятор может применить зная что никакие аргументы функции не являются псевдонимами.

Программист не может детально проанализировать все эти параметры для каждого параметра каждой функции — это слишком трудоемкая задача. Кроме того, результат будет разным для разных целевых аппаратных платформ. Так что, принятие этого решения целесообразно возложить на компилятор, а момент принятия решения перенести с времени написания кода на время компиляции.

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

А как быть с конструкторами копирования? Если для обычной функции семантика «для этого аргумента может быть вызван конструктор копирования» подходит, то для аргумента конструктора копирования она неприемлема, поскольку допускает возможность бесконечной рекурсии. Эту проблему можно решить как минимум двумя способами:

  1. Явно добавить исключение для конструктора копирования — компилятор всегда будет выбирать передачу по ссылке.
    class MyClass
    {
    public:
        MyClass(MyClass src) // На самом деле const MyClass& src.
        {
            ...
        }
    };
    
  2. Передавать аргумент в конструктор копирования по указателю и декорировать его каким-либо образом:
    class MyClass
    {
    public:
        MyClass(const MyClass* src, std::copy_ctor_tag)
        {
            ...
        }
    };
    

Теперь вернемся к перегрузке операторов.

В чистом C только ограниченный набор операторов может возвращать lvalue: доступ к массиву, различные виды присваивания, префиксные инкремент и декремент и само разыменование. Все. Для этих операторов можно изменить их способ отображения на функции, так чтобы те возвращали указатель:

a[i] = b;
*a.operator[](i) = b;
(++i) = x;
*i.operator++() = x;
(x = y) = z;
*x.operator=(y) = z;
*p = d;
*p.operator->() = d;

При этом оператор разыменования становится непергружаемым — вместо него, всю работу делает оператор ->.

Для всех других случаев возможность использования lvalue противоречит принципу наименьшего удивления — надеюсь, мне никогда не придется отлаживать код, в котором выражение «a + b» меняет один из своих аргументов, или во время ревью разбираться что значит запись «foo(42) = 7;».

Исключением подтверждающим правило являются потоки ввода-вывода. Передавать сам поток как аргумент оператору << нельзя — он будет передан по значению. Значит надо передать нечто, что будет ссылаться на объект потока и при этом сможет безопасно передаваться по значению. Это может быть указатель на поток, а лучше — специальный объект-обертка:

int main()
{
  std::fstream filestr("test.txt", fstream::out);
  std::outref(&filestr) << "foo = " << foo << ", bar = " << bar << std::endl;
  return 0;
}

std::outref operator<<(std::outref ref, MyClass obj)
{
    ref << obj.x;
    ref << obj.y;
    ref << obj.z;
    return ref;
}

Если я ничего не упустил, то очень похоже, что без ссылок в С++ вполне можно было обойтись.

Резюме

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

Автор: kjam

Поделиться

* - обязательные к заполнению поля