Что такое size_t и ptrdiff_t

Аннотация

Статья поможет читателю разобраться, что представляют собой типы size_t и ptrdiff_t, для чего они нужны и когда целесообразно их использование. Статья будет интересна разработчикам, начинающим создание 64-битных приложений, где использование типов size_t и ptrdiff_t обеспечивает быстродействие, возможность работы с большими объемами данных и переносимость между разными платформами.

Введение

Сразу заметим, что данные в статье определения и рекомендации относятся к наиболее распространненым на данный момент архитектурам (IA-32, Intel 64, IA-64), и могут быть неточны по отношению к экзотическим архитектурам.

Типы size_t и ptrdiff_t были созданы для того, чтобы осуществлять корректную адресную арифметику. Долгое время было принято считать, что размер int совпадает с размером машинного слова (разрядностью микропроцессора) и его можно использовать в качестве индексов, для хранения размеров объектов или указателей. Соответственно адресная арифметика также строилась с использованием типов int и unsigned. Тип int используется в большинстве обучающих материалов по программированию на Си и Си++ в телах циклов и в качестве индексов. Практически каноническим выглядит пример следующего вида:

for (int i = 0; i < n; i++)
  a[i] = 0;

С развитием микропроцессоров и ростом их разрядности стало нерационально дальнейшее увеличение размерностей типа int. Причин для этого много: экономия используемой памяти, максимальная совместимость и так далее. В результате появилось несколько моделей данных, описывающих соотношение размеров базовых типов языка Си/Си++. В таблице N1 приведены основные модели данных и перечислены наиболее популярные системы их использующие.

Таблица N1. Модели данных (data models)

Таблица N1. Модели данных (data models)

Как видно из таблицы, не так просто выбрать тип переменной для хранения указателя или размера объекта. Чтобы наиболее красиво решить эту проблему и появились типы size_t и ptrdiff_t. Они гарантированно могут использоваться для адресной арифметики. Теперь каноническим должен стать следующий код:

for (ptrdiff_t i = 0; i < n; i++)
  a[i] = 0;

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

Тип size_t

Тип size_t - базовый беззнаковый целочисленный тип языка Си/Си++. Является типом результата, возвращаемого оператором sizeof. Размер типа выбирается таким образом, чтобы в него можно было записать максимальный размер теоретически возможного массива любого типа. Например, на 32-битной системе size_t будет занимать 32-бита, на 64-битной - 64-бита. Другими словами в переменную типа size_t может быть безопасно помещен указатель. Исключение составляют указатели на функции классов, но это особый случай. Хотя в size_t можно помещать указатель, для этих целей лучше подходит другой беззнаковый целочисленный тип uintptr_t, само название которого отражает эту возможность. Типы size_t и uintptr_t являются синонимами. Тип size_t обычно применяется для счетчиков циклов, индексации массивов, хранения размеров, адресной арифметики.

Максимально допустимым значением типа size_t является константа SIZE_MAX.

Тип ptrdiff_t

Тип ptrdiff_t - базовый знаковый целочисленный тип языка Си/Си++. Размер типа выбирается таким образом, чтобы в него можно было записать максимальный размер теоретически возможного массива любого типа. На 32-битной системе ptrdiff_t будет занимать 32-бита, на 64-битной - 64-бита. Как и в size_t в переменную типа ptrdiff_t может быть безопасно помещен указатель, за исключением указателя на функцию класса. Также ptrdiff_t является типом результата выражения, где один указатель вычитается из другого (ptr1-ptr2). Тип ptrdiff_t обычно применяется для счетчиков циклов, индексации массивов, хранения размеров, адресной арифметики. У типа ptrdiff_t есть синоним intptr_t, название которого лучше отражает, что тип может хранить в себе указатель.

Переносимость size_t и ptrdiff_t

Типы size_t и ptrdiff_t позволяют писать переносимый код. Размер size_t и ptrdiff_t всегда совпадают с размером указателя. По этой причине именно эти типы следует использовать в качестве индексов больших массивов, для хранения указателей и арифметики с указателями.

Разработчики Linux приложений часто используют для этих целей тип long. В рамках 32-битных и 64-битных моделей данных, принятых в Linux это действительно работает. Размер типа long совпадает с размером указателя. Но такой код несовместим с моделью данных Windows и соответственно его нельзя считать хорошо переносимым. Более правильным решением будет использование типов size_t и ptrdiff_t.

Разработчики Windows в качестве альтернативы size_t и ptrdiff_t могут использовать типы DWORD_PTR, SIZE_T, SSIZE_T и так далее. Но желательно также ограничиваться типами size_t и ptrdiff_t.

Безопасность типов ptrdiff_t и size_t в адресной арифметике

Проблемы адресной арифметики стали активно проявлять себя с началом освоения 64-битных систем. Наибольшее число проблем при переносе 32-битных приложений на 64-битные системы связанно с использованием неподходящих для работы с указателями и массивами типов, таких как int и long. Этим проблемы переноса приложений на 64-битные системы не ограничивается, но большинство ошибок связаны именно с адресной арифметикой и работой с индексами.

Возьмем самый простой пример:

size_t n = ...;
for (unsigned i = 0; i < n; i++)
  a[i] = 0;

Если мы работаем с массивом, состоящим более чем из UINT_MAX элементов, то данный код является некорректным. При этом выявить ошибку и предсказать поведение данного кода не так просто. Отладочная (debug) версия зависнет, но редко кто в отладочной версии будет обрабатывать гигабайты данных. А вот рабочая (release) версия в зависимости от настроек оптимизации и особенностей кода, может как зависнуть, так и неожиданно корректно заполнить все ячейки массива, создавая иллюзию корректной работы. В результате в программе появляются плавающие ошибки, возникающие или пропадающие после малейшего изменения кода. Подробнее о таких фантомных ошибках и их опасностях можно познакомиться в статье "64-битный конь, который умеет считать" [1].

Пример еще одной дремлющей ошибки, которая проявит себя при определенном сочетании входных данных (значении переменных A и B):

int A = -2;
unsigned B = 1;
int array[5] = { 1, 2, 3, 4, 5 };
int *ptr = array + 3;
ptr = ptr + (A + B); //Error
printf("%i\n", *ptr);

Данный код будет успешно выполняться в 32-битном варианте и печатать на экране число "3". После компиляции 64-битном режиме при выполнении кода возникнет сбой. Рассмотрим последовательность выполнения кода и причину ошибки:

  • Переменная A типа int приводится к типу unsigned;
  • Происходит сложение A и B. В результате мы получаем значение 0xFFFFFFFF типа unsigned;
  • Вычисляется выражение "ptr + 0xFFFFFFFFu". Результат зависит от размерности указателя на данной платформе. В 32-битной программе, выражение будет эквивалентно "ptr - 1" и мы успешно распечатаем число 3. В 64-битной программе к указателю прибавится значение 0xFFFFFFFFu, в результате чего указатель окажется далеко за пределами массива.

Приведенные ошибки можно легко избежать, используя тип size_t или ptrdiff_t. В первом случае, если тип переменной "i" будет size_t, то не возникнет зацикливания. Во втором, если мы используем типы size_t или ptrdiff_t для переменных "A" и "B", то корректно распечатаем число "3".

Сформулируем совет: везде, где присутствует работа с указателями или массивами следует использовать типы size_t и ptrdiff_t.

Более подробно с тем, каких ошибок можно избежать, используя типы size_t и ptrdiff_t можно познакомиться в следующих статьях:

Быстродействие кода, использующего типы ptrdiff_t и size_t

Использование типов ptrdiff_t и size_t в адресной арифметике помимо повышения надежности кода может дать дополнительный выигрыш в производительности. Например, использование в качестве индекса типа int, размерность которого отличается от размерности указателя приводит к тому, что в двоичном коде будут присутствовать дополнительные команды преобразования данных. Речь идет о 64-битном коде, в котором размер указателей стал равен 64-битам, а размер типа int остался 32-битным.

Показать короткий пример преимущества size_t над unsigned не простая задача. Чтобы быть объективным необходимо использовать оптимизирующие возможности компилятора. А два варианта оптимизированного кода часто становятся слишком непохожим, чтобы легко было продемонстрировать отличие. Попытка создать нечто близкое к простому примеру увенчалась успехом только с шестой попытки. И все равно пример не идеален, так как показывает не лишние преобразование типов данных, о которых сказано выше, а то, что компилятор смог построить более эффективный код при использовании типа size_t. Рассмотрим код программы, переставляющий элементы массива в обратном порядке:

unsigned arraySize;
...
for (unsigned i = 0; i < arraySize / 2; i++)
{
  float value = array[i];
  array[i] = array[arraySize - i - 1];
  array[arraySize - i - 1] = value;
}

В примере переменные "arraySize" и "i" имеют тип unsigned. Этот тип легко можно заменить на тип size_t и сравнить небольшой участок ассемблерного кода, показанный на рисунке 1.

Рисунок N1. Сравнение 64-битного ассемблерного кода при использовании типов unsigned и size_t

Рисунок N1. Сравнение 64-битного ассемблерного кода при использовании типов unsigned и size_t

Компилятор смог построить более лаконичный код, когда использовал 64-битные регистры. Автор не берется утверждать, что код, созданный при использовании типа unsigned (текст слева), будет работать медленнее, чем код с использованием size_t (текст слева). Сравнить скорость выполнения кода на современных процессорах крайне сложная задача. Но из примера видно, что когда компилятор работает с массивами, используя 64-битные типы, он может строить более короткий и быстрый код.

По личному опыту автора, грамотная замена типов int и unsigned на ptrdiff_t и size_t может дать на 64-битной системе дополнительный прирост производительности до 10%. С одним из примеров увеличения скорости от использования типов ptrdiff_t и size_t можно познакомиться в четвертой главе статьи "Разработка ресурсоемких приложений в среде Visual C++" [5].

Рефакторинг кода с целью перехода на типы ptrdiff_t и size_t

Как читатель уже убедился, использование типов ptrdiff_t и size_t имеет ряд преимуществ для 64-битных программ. Но и заменить, скажем все unsigned на size_t - не является выходом. Во-первых, это не гарантирует корректность программы на 64-битной системы. Во-вторых, скорее всего из-за такой замены возникнут новые ошибки, нарушится совместимость форматов данных и так далее. Не стоит забывать, что после такой замены существенно возрастет и объем потребляемой программой памяти. Причем увеличение объема требуемой памяти может замедлить работу приложения, так как в кэш будет помещаться меньше объектов, с которыми идет работа.

Следовательно, внедрение в старый код типов ptrdiff_t и size_t является задачей постепенного рефакторинга, требующего большого количества времени. Фактически необходимо просмотреть весь код и внести необходимые исправления. Такой подход практически является слишком дорогостоящим и неэффективным. Можно предложить 2 варианта:

  • Использовать специализированные инструменты, такие как Viva64, входящий в состав PVS-Studio. Viva64 это статический анализатор кода, обнаруживающий места, где рационально изменить типы данных, чтобы программа была корректна и эффективно работала на 64-битных системах. Подробнее смотрите "Учебное пособие по PVS-Studio" [6].
  • Если 32-битную программу не планируется адаптировать для 64-битных систем, то и нет смысла заниматься рефакторингом типов данных. 32-битная программа не получит никаких преимуществ от использования типов ptrdiff_t и size_t.

Библиографический список