Содержание


Малоизвестные возможности и последние нововведения в языке C. Часть 2. Точность вычислений и локализация в приложениях на языке С

В этой статье мы продолжаем знакомство с нововведениями, появившимся в последних версиях стандарта языка С, и рассмотрим вопросы точности вычислений и работы с данными в кодировке UNICODE.

Точность вещественных вычислений

После введения в стандарте C89 вещественного типа long double и его дальнейшего развития в стандарте C99, в языке C существует 3 типа вещественных данных, отличающихся разрядностью (диапазоном, точностью представления): float, double, long double. Детали реализации в стандарте не описаны и оставлены на усмотрение разработчиков компилятора, поэтому различные компиляторы могут обрабатывать одни и те же значения по-разному.

В листинге 1 представлен пример работы в высокоточными значениями в компиляторе GCC (полный код примера можно найти в файле float.c в архиве lang_c_details.tgz в разделе "Материалы для скачивания").

Листинг 1. Различия в точности вещественных значений (файл float.c)
const long double PI = 3.1415926535897932384626433832795028841971\
6939937510582097494459230781640628620899862803482534211706798214808651\
3282306647093844609550582231725359408128481117450284102701938521105559\
...
8142061717766914730359825349042875546873115956286388235378759375195778\
18577805321712268066130019278766111959092164201989L;

void test010( void ) {      // "неточность" вещественных данных
   TITLE( "точность представления вещественных типов" );
   long double rl = (long double)PI;
   double rd = (double)PI;
   float rf = (float)rd;
   printf( "%12s[%2d байт] : %15.12Lf - %15.12f = %15.12Le\n",
           "float", sizeof( rf ), rl, rf, rl - (long double)rf );
   printf( "%12s[%2d байт] : %15.12Lf - %15.12f = %15.12Le\n",
           "double", sizeof( rd ), rl, rd, rl - (long double)rd );
   printf( "%12s[%2d байт] : %15.12Lf - %15.12Lf = %15.12Le\n",
           "long double", sizeof( rl ) , rl, rl, rl - rl );
}

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

$ ./float 0
компилятор GCC :
00 ---------------------------------------
точность представления вещественных типов (файл: float.c):
       float[ 4 байт] :  3.141592653590 -  3.141592741013 = -8.742278000367e-08
      double[ 8 байт] :  3.141592653590 -  3.141592653590 = 1.225148454909e-16
 long double[12 байт] :  3.141592653590 -  3.141592653590 = 0.000000000000e+00
------------------------------------------

Очень часто вещественные результаты вычисляются циклическим итерационным процессом (вычисление функций с помощью сходящихся рядов, решение нелинейных уравнений и т.д.), повторяемым до достижения некоторой требуемой точности. Часто требуемая сходимость (величина невязки) указывается константой, например 1.0E-9. Но величина погрешности после очередного шага не может быть указана сколь угодно малой, так как, начиная с некоторой величины малости, сумма этой величины с 1.0 становится неразличимой со значением 1.0. Подобная ситуация называется потерей точности, и связана как раз с приближённостью вещественных представлений. В листинге 2 показано, как можно определить порядок минимально различимой (относительной) погрешности.

Листинг 2. Максимальная точность итерационных вычислений (файл float.c).
#define DIV 10.

void test060( void ) {    // максимальная точность итерационных вычислений
   TITLE( "максимальная точность итерационных вычислений" );
   int i;
   float x = 1.;
   double y = 1.;
   long double z = 1.;
   for( i = 0; ; i++ ) {
      if( (float)1. + x == (float)1. + x / (float)DIV ) break;
      x /= (float)DIV;
   }
   printf( "для float\t: %e (число итераций %d)\n", x, i );
   for( i = 0; ; i++ ) {
      if( ( (double)1. + y ) == ( (double)1. + y / (double)DIV ) ) break;
      y /= (double)DIV;
   }
   printf( "для double\t: %e (число итераций %d)\n", y, i );
   for( i = 0; ; i++ ) {
      if( ( (long double)1. + z ) == ( (long double)1. + z / (long double)DIV ) ) break;
      z /= (long double)DIV;
   }
   printf( "для long double\t: %Le (число итераций %d)\n", z, i );
}

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

$ ./float 1
компилятор GCC :
01 ---------------------------------------
максимальная точность итерационных вычислений (файл: t060.c):
для float		: 1.000000e-08 (число итераций 8)
для double		: 1.000000e-16 (число итераций 16)
для long double	: 1.000000e-20 (число итераций 20)
------------------------------------------

Поддержка UNICODE и локализация приложений

В стандарте C89 в язык С была добавлена поддержка языковых локализаций, и впоследствии значительно расширена в стандарте C99. Поддержка широких символов (UNICODE), многобайтовых и двухбайтовых функций (<wchar.h> и <wctype.h>) была добавлена в 1995 году Поправкой 1.

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

Современные дистрибутивы Linux на сегодня повсеместно используют представление строк в UNICODE путём кодирования в UTF-8 (раньше чаще всего использовалась кодировка KOI-8R). Проще всего проверить локаль, используемую вашей системой, командой locale, как показано ниже:

$ locale
LANG=ru_UA.utf8
LANGUAGE=
LC_CTYPE="ru_UA.utf8"
LC_NUMERIC="ru_UA.utf8"
LC_TIME="ru_UA.utf8"
LC_COLLATE="ru_UA.utf8"
...
LC_IDENTIFICATION="ru_UA.utf8"
LC_ALL=

Все категории (константы) локализации (LC_ALL, LC_CTYPE, …) представляются в программном коде как ASCIIZ символьные строки. Командой locale -a можно посмотреть все доступные в системе языковые локали, и даже определить новые, но это выходит за рамки нашего цикла.

В кодировке UTF-8 каждый символ UNICODE может представляться многобайтовой последовательностью от 1 до 4 байт (основной набор ASCII, английские символы, представляются 1-м байтом, весь русскоязычный набор — 2-мя байтами). Поскольку текстовые редакторы используют ту же кодировку UTF-8, что и терминал при выводе (и только поэтому), до тех пор, пока локализованные строки используются только как символьные константы для вывода на экран, для них может использоваться традиционное представление в виде char[ ] (при этом стоит помнить, что для хранения строки в 10 визуальных символов может понадобиться буфер размером в 20 байт). Но как только с локализованными строками предполагается выполнять любые манипуляции (подсчёт символов, реверсирование, выделение полей, слов, …) они обязательно должны быть преобразованы к типу широких символов wchar_t[ ], чтобы избежать серьёзных проблем.

void test01( void ) {
   printf( "размер символа wchar_t в реализации = %d байт\n", sizeof( wchar_t ) );
}

Любой символ wchar_t (независимо от языка, кодовой страницы) представляется 4-мя байтами, так же, как и любой символ в таблицах UNICODE описывается 4-х байтным кодом:

$ ./unicode 0
00 ---------------------------------------
размер символа wchar_t в реализации = 4 байт
------------------------------------------

Для работы с символьными последовательностями в кодировке UTF-8 и преобразования их в строки wchar_t и обратно используются мультибайтные функции группы mb*() (например mblen(), mbrlen(), ...).

Но функции преобразования (mbtowc(), mbstowcs() и др.) будут работать, только если предварительно установлена языковая локаль (LC_CTYPE), в которой выражены строки, в противном случае будет возвращаться ошибка.

void test02( void ) {
   char *loc = setlocale( LC_ALL, NULL );   // показать текущую локаль
   printf( "локаль программы по умолчанию: %s\n", loc );
}

Но программа, скомпилированная компилятором GCC, будет использовать локаль C:

$ ./unicode 1
01 ---------------------------------------
локаль программы по умолчанию: C
------------------------------------------

В итоге потребуется установить локаль прямо в программе, обычно выбрав default локаль операционной системы, и уже после этого преобразовать char[] многобайтные (2-х байтные для русского языка) последовательности в широкие wchar_t[] последовательности, как показано в листинге 3.

Листинг 3. Преобразование UTF-8 в wchar_t (файл Unicode.c)
#include <wchar.h>
#include <locale.h>

#define LENGTH 160
char    buf  [ LENGTH ] = "тестовая русскоязычная строка в UTF-8 ...";
wchar_t wbuf [ LENGTH ];

void test03( void ) {
   // только после этого преобразования будут работать!
   char *loc = setlocale( LC_ALL, "" );
   int n = -1, i;
   char *p;
   printf( "преобразование UTF-8 символов в широкие (wchar_t):\n" );
   printf( "локаль программы установлена: %s\n", loc );
   printf( "строка UTF-8 до преобразования: '%s'\n"
           "длина UTF-8 строки = %d байт\n",
           buf, strlen( buf ) );
   for( i = 0, p = (char*)buf; n != 0; i++ )
      p += ( n = mbtowc( wbuf + i, p, MB_CUR_MAX ) );
   printf( "преобразованная строка: '%ls'\n"
           "длина преобразованной строки = %d символов (%d байт)\n",
           wbuf, wcslen( wbuf ), wcslen( wbuf ) * sizeof( wchar_t ) );
}

Для работы с широкими символами wchar_t представлен обширный набор функций, эквивалентных классическим str*()-функциям, для работы с традиционными ASCII строками. Только вместо префикса str в именах этих аналогов используется префикс wcs (например, wcslen() вместо strlen()). В листинге 4 выполняется пословный реверс показанной выше русскоязычной строки (реверс выполнен рекурсивно):

Листинг 4. Манипуляции со строками wchar_t (файд unicode.c)
static void revers( wchar_t *w ) {   // реверс строки
   wchar_t *sec, wb[ 40 ];
   if( NULL == ( sec = wcschr( w, L' ' ) ) ) return;
   wcsncpy( wb, w, sec - w )[ sec - w ] = L'\0';
   while( L' ' == *sec ) sec++;
   revers( sec );
   wcscat( wcscat( wmemmove( w, sec, wcslen( sec ) + 1 ), L" " ), wb );
}

void test05( void ) {
   while( L' ' == wbuf[ wcslen( wbuf ) - 1 ] )
      wbuf[ wcslen( wbuf ) - 1 ] = L'\0';
   printf( "устранение завершающих пробелов: '%ls'\n", wbuf );
   revers( wbuf );
   printf( "реверсирование слов: '%ls'\n", wbuf );
   revers( wbuf ); // выполняется дважды для возвращения к исходному виду
   printf( "реверсирование слов: '%ls'\n", wbuf );
}

После манипуляций с отдельными символами строки wchar_t[], её, как правило, нужно преобразовать обратно (wctomb(), wcstombs()) в UTF-8 строку в char[] (например, для сохранения в файл, пересылке через сетевой сокет и др.). Исключение составляет операция вывода UNICODE строки на терминал, для которой добавлен специальный спецификатор %ls для формата printf(), как наиболее часто выполняемой операции со строками, как показано в листинге 5.

Листинг 5. Обратное преобразования wchar_t строки в UTF-8 (файл unicode.c)
void test07( void ) {
   int n;
   printf( "обратное преобразование в UTF-8: %d байт\n", \
            n = wcstombs( NULL, wbuf, 0 ) );
   wcstombs( buf, wbuf, n + 1 ); // с завершающим нулём
   printf( "преобразованная UTF-8 строка: '%s'\n", buf );
   strcpy( buf, "" );
   sprintf( buf, "%ls", wbuf );
   printf( "преобразованная UTF-8 строка: '%s'\n", buf );
}

В листинге 5 показано, что за счёт нового спецификатора формата преобразование в UTF-8 может быть выполнено не только специальными функциями, но и простым форматированием в строку применением sprintf().

Ниже показан результат последовательного запуска всех 3-х обсуждаемых выше фрагментов:

$ ./unicode 2 3 4
02 ---------------------------------------
преобразование UTF-8 символов в широкие (wchar_t):
локаль программы установлена: ru_UA.utf8
строка UTF-8 до преобразования: 'тестовая русскоязычная строка в UTF-8 ...'
длина UTF-8 строки = 110 байт
преобразованная строка: 'тестовая русскоязычная строка в UTF-8  ...'
длина преобразованной строки = 63 символов (252 байт)
03 ---------------------------------------
устранение завершающих пробелов: 'тестовая русскоязычная строка в UTF-8 ...'
реверсирование слов: '... UTF-8 в строка русскоязычная тестовая'
реверсирование слов: 'тестовая русскоязычная строка в UTF-8 ...'
04 ---------------------------------------
обратное преобразование в UTF-8: 107 байт
преобразованная UTF-8 строка: 'тестовая русскоязычная строка в UTF-8 ...'
преобразованная UTF-8 строка: 'тестовая русскоязычная строка в UTF-8 ...'
------------------------------------------

Здесь хорошо видно, что тестовая строка из 63 символов требует в UTF-8 представлении 110 байт для хранения, а после преобразования в wchar_t[ ] она занимает 63*4=252 байт, но в таком виде к ней могут быть применены уже любые строчные преобразования. Ещё один полезный вывод состоит в том, что нельзя напрямую оценить объём (в байтах), необходимый для хранения реального символьного содержимого.

Примечание: При реверсировании слов, показанном выше, можно было бы обойтись без преобразования в широкие символы, но только за счёт того, что символ разделитель ' ' (пробел) представлен символом ASCII, а последовательность символов, составляющих слово, нужно копировать в неизменном виде. Поэтому показанное решение не учитывает содержимого байт, составляющих символы и слова. Любые же другие операции с содержимым, например, посимвольный реверс строки, требуют обязательного преобразования, так как без него результаты операции были бы катастрофическими. В качестве примера рассмотрим код из листинга 6.

Листинг 6. Ошибочное реверсирование символьной строки (файл unicode.c):
void test07( void ) {

static char* revb( char *s ) {
   int i, j;
   for( i = 0, j = strlen( s ) - 1; i <= j; i++, j-- ) {
      char c = s[ i ];
      s[ i ] = s[ j ];
      s[ j ] = c;
   }
   return s;
}

void test09( void ) {
   char se[] = "abcdefghijklmnopqrstu",
        sr[] = "абвгдеёжзиклдмнопрсту";
   printf( "%s => %s\n", se, revb( strdup( se ) ) );
   printf( "%s => %s\n", sr, revb( strdup( sr ) ) );
}

Выполнив такое преобразование, можно убедиться, что оно работает совсем не так, как мы ожидали, правильно - для латинских ASCII строк, но некорректно для иноязычных UTF-8 строк:

$ ./unicode 5
компилятор Clang :
05 ---------------------------------------
abcdefghijklmnopqrstu => utsrqponmlkjihgfedcba
абвгдеёжзиклдмнопрсту => �тсрѿонмдлкизжБѵдгвба
------------------------------------------

Работа со списочными структурами

Вся информация, представленная в этом разделе, не относится к самому языку C, а связана с традициями, сложившимися в ходе многолетней разработки ядра Linux. Но эти приёмы могут с успехом применяться и в прикладных разработках на языке C.

Обычно односвязный линейный список, как он традиционно реализуется в учебных руководствах по C, использует полем для связи со следующим элементом, или равным NULL как признак завершающего элемента списка. В файле list.c в архиве lang_c_details.tgz можно посмотреть пример подобной реализации.

Вместо "классического" решения, в ядре Linux (начиная с версий 2.6) предлагается все динамически связные структуры (списки, очереди, деревья, графы и д.р.) строить на базе двунаправленных кольцевых списков. И в заголовочном файле ядра (source/include/linux/list.h) предлагается базовая структура list_head_t и большой набор макросов на все случаи работы с ней. В листинге 7 представлен способ решения аналогичной задачи с помощью новой функциональности.

Листинг 7. Двунаправленный кольцевой список (файл list.c)
// from kernel: "/lib/modules/.../source/include/linux/list.h"
typedef struct list_head {
   struct list_head *next, *prev;
} list_head_t;

void INIT_LIST_HEAD( list_head_t *list ) {
   list->next = list;
   list->prev = list;
}

list_head_t* list_add_after( list_head_t *new, list_head_t *node ) {
   node->next->prev = new;
   new->next = node->next;
   new->prev = node;
   node->next = new;
   return new;
}

typedef struct node {   // структура узла списка
   list_head_t link;
   int data;            // собственные данные узла
} node_t;

void test030( void ) {
   node_t nd = { {}, 1 }, *head = &nd;
   INIT_LIST_HEAD( &head->link );
   int i;
   list_head_t *lhead = &head->link, *pc = lhead;
   for( i = 0; i < SIZE; i ++ )
      ((struct node*)( pc = list_add_after(
                         (list_head_t*)alloca( sizeof( node_t ) ), pc
                                          )
                     ))->data = i + 2;
   pc = lhead;   
   do {                        // обход кольцевого списка вперёд
      printf( "%d, ", ((struct node*)pc)->data );
      pc = pc->next;
   } while( pc != lhead );
   pc = pc->prev;
   do {                        // обход кольцевого списка назад
      printf( "%d, ", ((struct node*)(pc->prev))->data );
      pc = pc->prev;
   } while( pc != lhead );
   printf( "\n" );
}

Теперь мы получили возможность двигаться по элементам списка в двух направлениях (вперёд и назад):

$ ./list 1
01 ---------------------------------------
1, 2, 3, 4, 5, 6, 7, 8, 7, 6, 5, 4, 3, 2, 1,
------------------------------------------

Но главными достоинствами такого решения является не двунаправленность, а два факта:

Во-первых, элементы списка не содержат концевых элементов NULL как признаков конца. При этом исключается аварийное завершение процесса при ошибочном разыменовании NULL-указателя в процессе отладки или выполнения. Теперь признаком конца списка (в обоих направлениях) является совпадение указателя связи с началом списка (списки кольцевые).

Во-вторых, на базе типа list_head_t предлагается строить связные структуры любого типа. Для многосвязных структур (B-деревьев, связных графов) предлагается иметь несколько элементов list_head_t в структуре узла. Из-за наличия большого количества заранее подготовленных макросов (процедур) манипуляций с list_head_t, операции на динамически связанных структурах становятся простыми и безошибочными.

Показательно, что все многочисленные определения и инструменты для работы с list_head_t присутствуют в заголовочных файлах ядра Linux, но они не нашли места в заголовочных файлах библиотек GCC (/usr/include). Это лишний раз напоминает, что разработчики проектов GNU и разработчики ядра Linux — это совершенно разные команды.

Заключение

В данной статье мы познакомились с особенностями работы с текстовыми данными в формате UNICODE и способом реализации списочных структур, принятом в среде Linux-разработчиков.

В следующей статье мы завершим обсуждение "тонкостей" использования языка C на примере специфических особенностей передачи параметров и возврата значений из функций и а также использования компилятора Clang для работы с кодом, написанным на языке C.


Ресурсы для скачивания


Похожие темы

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Open source
ArticleID=968033
ArticleTitle=Малоизвестные возможности и последние нововведения в языке C. Часть 2. Точность вычислений и локализация в приложениях на языке С
publish-date=04092014