GNU C: Рекуррентный цикл и инвариантность в нем данных

К оглавлению


Что это такое

Алгоритм цикла, оперирующий с одной и той же последовательностью данных, должен быть инвариантен, т.е отвечать условию изменения их значений на каждом проходе к некоторой константе и/или порождающему её утверждению, а так же сохранять истинность до своего начала и при выходе из него.

1. Утверждения

1.1 Инвариантность алгоритма цикла (далее – цикл), как и сам инвариант, имеют право на существование тогда и только тогда, когда цикл оперирует с одними и теми же переменными, являющиеся рекуррентным, т.е. изменяют свое состояние от предыдущего к последующему.

(п.2.1 настоящего документа)

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

(п.2.2 настоящего документа)

1.3 Цикл должен быть инвариантен на всех своих проходах после входа в него.

(п.2.3 настоящего документа)

1.4 Цикл должен быть инвариантен в момент выхода из него и после его завершения.

(п.2.4 настоящего документа)

2. Доказательство

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

2.1. Доказательство инвариантности цикла

Пусть существует некоторый алгоритм цикла, состоящий из последовательности команд, которые представляют собой выражение (у математиков – функция) $F(X_k)$, содержащее как минимум один оператор и одну, две и более переменных, значения которых изменяются от одного прохода цикла к другому. Поэтому проверке подлежат соотношение состояний всех задействованных в цикле переменных из общего набора $X_k=(x_k,y_k,...,z_k)$, которые изменяют свое состояние на каждой $k-ой$ итерации или прохода выполнения цикла. При этом, выражение $F(X_k)$ должно быть истинно на всем протяжении выполнения цикла.

(продолжение доказательства рекуррентности цикла в п.2.3)

2.2. Цикл должен быть инвариантен до первого прохода

Состояние всех переменных цикла $X_k$ должно быть инвариантно начиная с момента инициализации цикла, а не входа в него. Таким образом, инвариант $I(X_0)$ должен возвращать истинное состояние всех переменных $X_0$ перед началом выполнения цикла, а не после входа в него. Следовательно:

$I(X_0) \Rightarrow G(X_0)$(1)

Данная проверка вытекает из-за того, что на нулевой итерации, предваряющей выполнение цикла, решается вопрос: "входить в него или нет?"

Поэтому, кроме условия инвариантности цикла, должна проводиться одновременно проверка на истинность достяжения им целевого назначения $G(X_0)$, т.е. условие выполнения цикла наступило и "истинно" или не наступило, т.е. "ложно". Если оно наступило и истинно, входить в цикл не имеет смысла. В обратном случае, побежали дальше.

При этом вне зависимости оттого бежим мы или не бежим, инвариант $I(X_0)$ будет всегда "истинным", в то время как целевое условие $G(X_0)$ может быть как "истинным" (бежим), так и "ложным" (не бежим).

2.3. Цикл должен быть инвариантен на всех своих проходах

После выполнения входа в цикл и до конца первого прохода выражением $F(X_{k-1})$ переменным будут присвоены новые значения из общего набора $X_k$ :

$X_k \leftarrow F(X_{k-1})$(2)

Затем, выполняется проверка на соблюдение условий инвариантности и целевого назначения цикла по формуле (1), только с одним изменением, что счетчик итераций цикла $к-й$ принимает значения $1,2,3,...,N$:

$I(X_k) \Rightarrow G(X_k)$(3)

Таким образом, мы имеем дело с рекуррентным циклом, в котором каждому предыдущему состоянию всех переменных $X_{k-1}$ находится последующее $X_k$ (окончание доказательства п.2.1). Поэтому, когда он закончит свою работу, целевое условие $G(X_k)$ станет истинным. Отсюда, нам необходимо взять выражение $\lnot G(X_k)$ перед началом каждого $k-го$ прохода:

Алгоритм 1:

$k \leftarrow 0 $ $X_0 = (x_0,y_0,...,z_0)$ $\mathbf{While} \lnot G(X_k)$ $\mathbf{do} $ $\mathbf{if}$ $I(X_k)$ $\mathbf{then}$ $X_k \leftarrow F(X_{k-1})$ $k \leftarrow k + 1$

2.4. По окончанию цикла соблюдается истинность инварианта и условия его окончания цикла

Обычно, при доказательстве инвариантности цикла используют более простой алгоритм:

Алгоритм 2:

$\\ k \leftarrow 0 $ $X_0 = (x_0,y_0,...,z_0)$ $\mathbf{While} \lnot Q(X_k) \land I(X_k)$ $\mathbf{do}$ $X_k \leftarrow F(X_{k-1})$ $k \leftarrow k + 1 $

Здесь вместо целевого условия $G(X_k)$ используется условие окончания цикла $Q(X_k)$, которое с одной стороны упрощает алгоритм цикла, но с другой – затрудняет доказательство его инвариантности.

Дело в том, что целевое условие цикла $G(X_k)$ и условие его окончания $Q(X_k)$ являются с точки зрения логики доказательства двумя разными событиями, хотя по смыслу являются одним и тем же. Поэтому при написании программного кода они реализуются через одно и то же условие, принимающее "ИСТИНУ" или "ЛОЖЬ".

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

Алгоритм 3:

$k \leftarrow 0$ $X_0 = (x_0,y_0,...,z_0)$ $\mathbf{While} \lnot G(X_k)$ $\mathbf{do}$ $X_k \leftarrow F(X_{k-1})$ $\mathbf{if} \ \lnot Q(X_k) \land I(X_k) $ $\mathbf{then}$ $ k \leftarrow k + 1 $ $\mathbf{ G(X_k) \Leftarrow Q(X_k) \land I(X_k)}$  

В алгоритме 3 одновременная истинность инварианта $I(X_k)$ и условия окончания цикла $Q(X_k)$ влекут наступление целевого условия $G(X_k)$, истинность которого неизбежно приведет к его завершению и одновременно доказывая, что цикл завершится в том случае, если наступит условие его завершения $Q(X_k)$ при одновременной истинности инварианта $I(X_k)$, тем самым доказывается инвариантность цикла и верность выбранного алгоритма.

3. От теории к практике

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

Алгоритм 4:

$\\ k \leftarrow 0$ $X_0 = (x_0,y_0,...,z_0)$ $\require{cancel}\bcancel{\mathbf{While} \lnot G(X_k)}$ $\mathbf{While} \lnot Q(X_k) \land I(X_k)$ $\mathbf{do}$ $X_k \leftarrow F(X_{k-1})$ $\require{cancel} \bcancel{\mathbf{If} \ I(X_k)}$ $\bcancel{\mathbf{then}}$ $ k \leftarrow k + 1 $ $\require{cancel} \bcancel{\mathbf{ G(X_k) \Leftarrow Q(X_k) \land I(X_k)}}$

Как видно из алгоритма 4 из него исключена оценка наступления целевого условия цикла $G(X_k)$, так как оно согласно п.2.4 тождественно условию окончанию цикла $Q(X_k)$ и проверки на истинность инварианта $I(X_k)$:

$G(X_k) \equiv Q(X_k) \land I(X_k) $

При этом было бы разумным оставить проверку истинности инварианта $I(X_k)$, при котором производится увеличение счетчика проходов цикла $k$, но алгоритм 2 её делает избыточной, так как в начале каждого прохода это делается неизбежно.

4. От практики к реализации

В языке программирования GNU C, для работы с рекуррентными циклами используются операторы while, do и for. При этом, цикл реализованный через операторы while и for подразумевают, что цикл инвариантен до первого прохода. Иначе, в соответсвие с описанием for и while, они завершаться ещё до первого прохода, в то время как do предлагает это делать в конце выполнения цикла вместе с проверкой на условие его завершения $Q(X_k)$.

4.1 Оператор for

Данный оператор предполагает в одной записи сразу несколько действий:

Действие 1 (initialize)

инициализирует общий набор переменных $X_0=(x_0,y_0,...,z_0)$, т.е. присваивает начальное состояния переменным до вхождения в цикл, когда $k \leftarrow 0$;

Действие 2 (test)

осуществляет проверку на условие завершения $Q(X_k)$ и (или) истинность инварианта $I(X_k)$ перед выполнением первого прохода, когда $k \leftarrow 0$, поэтому перед первым проходом и последующим будем проводить проверку на условие завершения цикла $Q(X_k)$ и истинности инварианта $I(X_k)$, что не противоречит ранее доказанным утверждениям инвариантности цикла в п.п 2.2 и 2.3;

Действие 3 (statement)

производит изменение состояние переменных $X_{k+1} \leftarrow F(X_k)$

Действие 4 (step)

изменяет значение счетчика проходов $k$ на один, т.е. $k \leftarrow k + 1$

Таким образом, основная форма записи может иметь следующий вид:

Форма записи 1:

$\mathbf{for} ( initialize; test; step )$ $statement$ Где $initialize:$ $X_0=(x_0,y_0,...,z_0)$, $ k \leftarrow 1$ $test:$ $\ \lnot Q(X_k) \land I(X_k)$ $statement:$ $X_k \leftarrow F(X_{k-1})$ $step:$ $k \leftarrow k + 1$

Учитывая, что проверка на истинность инварианта $I(X_k)$ может производится до присвоения состояний набору переменных $X_k$, поэтому в форму записи 1 можно привести к следующему:

Форма записи 1а:

$\mathbf{...}$ $test:$ $\ \lnot Q(X_k)$ $statement:$ $\mathbf{if}$ ( $I(X_k)$ ) { $X_k \leftarrow F(X_{k-1})$ }else $\mathbf{break};$ $\mathbf{...}$

Таким образом, форма записи 1a доказывает право на жизнь алгоритма 4 с одним отличаем, что цикл будет принудительно завершен из-за отсутствия истинности инварианта $I(X_k)$. Также в этом случае можно насильно присваивать false условию окончания цикла $Q(X_k)$, но быстрее будет воспользоваться оператором break, который немедленно завершит цикл.

Листинг 1:


 #define CARRAY_LEN    11

 char 	carray_buf[CARRAY_LEN] = { 0x30 }, /* массив символов содержит 
	        			       следующие их коды символов: 
					       '0','1'-'9' и ':' */
	*go_p = (char *)carray_buf+1;    /* смещение на незаписанный символ, 
                                            потому что начальное состояние X[k] 
                                            инвариантно */
 int	i;
/*  проверка на условие окончание цикла осуществляется 
    в test */
 for ( i=1 ; !ispunct(*go_p); i++, go_p++ ) {  
     *go_p = *(go_p-1) + 1;
     if( isdigit(*go_p) ) { /* проверка на истинность инварианта  */                                    
          fprintf(stdout,"Step %d: '%c'(%02X)\n", i, *go_p, *go_p );
     }else 
        break;
 }
 fprintf(stdout,"Step %d: end of loop.\n", i );

Если же условия завершения $Q(X_k)$ и истинности инварианта $I(X_k)$ тождественны, то может иметь место следующая запись:

Форма записи 2:

$\mathbf{for} ( initialize;; step )$ $statement$ Где $initialize:$ $X_0=(x_0,y_0,...,z_0)$, $ k \leftarrow 1$ $statement:$ $\mathbf{if}$ ( $I(X_k)$ ) { $X_k \leftarrow F(X_{k-1})$ }else $\mathbf{break};$ $step:$ $k \leftarrow k + 1$

Здесь проверка на условие завершения $Q(X_k)$ опущена, так как проводится одновременно с проверкой на условие истинности инварианта $I(X_k)$.

Ниже, в листинге 2 приводится код реализации рекуррентного цикла для формы записи 2.

Листинг 2:


 char 	carray_buf[CARRAY_LEN] = { 0x30 }, /* массив символов содержит 
	        			       следующие их коды символов: 
					       '0','1'-'9' и ':' */
	*go_p = (char *)carray_buf+1;    /* смещение на незаписанный символ, 
                                            потому что начальное состояние X[k] 
                                            инвариантно */
 int	i;

 for ( i=1 ; ; i++, go_p++ ) {  
     *go_p  = *(go_p-1) + 1;
     if( isdigit(*go_p) ) { /* проверка на истинность инварианта */                                   
          fprintf(stdout,"Step %d: '%c'(%02X)\n", i, *go_p, *go_p );
          continue;
     }
     if ( ispunct(*go_p) ) {  /* Проверка на условие окончание цикла */ 
        fprintf(stdout,"Step %d: end of loop.\n", i );
        break;
     }
 }

Последовательность символов для их кодировок в ascii и её расширения utf-8 всегда будет следующей:

Базовый код/ смещение +00h +01h +02h +03h +04h +05h +06h +07h +08h +09h +0Ah
0030h '0' '1' '2' '3' '4' '5' '6' '7' '8' '9' ':'

Таким образом, проверка на условие окончания цикла с помощью ispunct() избыточна, впрочем как инкремент и инициализация значения переменной i , поэтому код в листинге 2 можно привести к виду, как показано в листинге 3.

Листинг 3:


 . . .
      *go_p = (char *)carray_buf+1;    /* смещение на незаписанный символ, 
                                          потому что начальное состояние X[k] 
                                          инвариантно */
 int	i=1;
 
 . . .

 for ( ; ; ) {  
	*go_p = *(go_p-1) + 1; 
	if( isdigit(*go_p) ) { /* проверка на истинность инварианта  */                                   
          fprintf(stdout,"Step %d: '%c'(%02X)\n", i++, *go_p, *go_p );
          continue;
	}
        if( ispunct(*go_p++) ) { /* проверка на условие окончание цикла */
          fprintf(stdout,"Step %d: end of loop.\n", i );
          break;
        } 
      }
 . . .

Для оптимизации кода внутри $statement$ оператора for (см. листинги 2 и 3) можно добавить ещё две формы его записи, как показано ниже.

Форма записи 3:

$\mathbf{for} ( ; ; step )$ $statement$

Форма записи 4:

$X_0=(x_0,y_0,...,z_0)$, $ k \leftarrow 0$ $\mathbf{for} ( ; ; )$ $statement$ Где $statement$: $\mathbf{if}$ ( $I(X_k)$ ) { $k \leftarrow k + 1$ $X_k \leftarrow F(X_{k-1})$ }else $\mathbf{break};$

Форма записи 4 обуславливает переход к оператору while, а именно к форме записи 5.

4.2 Оператор while

Цикл реализуемый через оператор while имеет единственную форму записи, в том числе широко используемое её следствие.

Форма записи 5:

$\mathbf{while} ( test )$ $statement$ Где $tеst$: $I(X_k)$ $k \leftarrow k + 1$ $statement$: $X_k \leftarrow F(X_{k-1})$

Ниже, форма записи 6 является следствием 5-й формы записи, хотя имеет существенные отличия от неё

Форма записи 6:

$\mathbf{while} ( 1 )$ $statement$ Где $test:=1$ $statement$: $X_k \leftarrow F(X_{k-1})$ $\mathbf{if}$ ($\ \lnot I(X_k)$ ) $\mathbf{break}$ $k \leftarrow k + 1$

Во-первых, $test$ оператора for (см. форму записи 6) всегда равно 1, т.е. истина.

Во-вторых, инвариант цикла $I(X_k)$ проверяется не на истинность условия, а на ложное его состояние для своевременного прекращения работы цикла.

Таким образом, доказано то, что форма записи 6 является следствием формы записи и является обратным доказательством формы записи 5.

В листинге 4 приведен пример реализации формы записи 5.

Листинг 4:


 . . .
  
 *go_p = (char *)carray_buf+1;    /* смещение на незаписанный символ, 
                                     потому что начальное состояние X[k] 
                                     инвариантно */
 int	i=1;

 . . .
  
 while( !ispunct(*(go_p)) && isdigit(*(go_p-1)) ) {  /* Одновременная проверка на целевое условие и 
                                                        на ложное условие инварианта цикла */
   fprintf(stdout,"Step %d: '%c'(%02X)\n", i++, *go_p, *go_p ); 
   *go_p = *((go_p++)-1) + 1; 
 }
 fprintf(stdout,"Step %d: end of loop.\n", i );
 . . .

В то же время в листинге 5 приведен пример реализации формы записи 6.

Листинг 5:


 . . .
      *go_p = (char *)carray_buf+1;  /* тоже самое как и в предыдущем примере */    

 int	i=1;
 . . .
      while( 1 ) {   
	*go_p  = *(go_p-1) + 1; 
	if( !isdigit(*go_p) ) { /* проверка на истинность инварианта является
                                   в тоже время и проверкой на условие окончания 
                                   цикла  */                                   
          fprintf(stdout,"Step %d: end of loop.\n", i );
          break;
	}
        /* бегунок будет смещен на одну позицию вперед тогда 
           и только тогда, когда истинен инвариант */
        fprintf(stdout,"Step %d: '%c'(%02X)\n", i++, *go_p, *go_p++ );
      }
 . . .

При этом в листинге 5 отсутствует проверка начального состояния $X_k$. К тому же, как можно заметить, листинг 4 более оптимален чем последний, поэтому форму записи 6 не всегда целесообразно использовать в рекуррентном цикле.

4.3 Оператор do

Логика действия данного оператор схожа с оператор while, но с двумя существенными отличиями:

Первое:

$statement$ выполняется до $test$, как показано в форме записи 7.

Второе:

$test$ проводит проверку на условие окончания цикла для прохода ${k-1}$, потому что перед её выполнением будет произведено увеличение значения счетчика с $k-го$ на $k+1$.

Форма записи 7:

$\mathbf{do} $ $statement$ $\mathbf{while} ( test )$ Где $statement$: $\mathbf{if}$ ($\ \lnot I(X_k)$ ) $\mathbf{break}$ $X_k \leftarrow F(X_{k-1})$ $k \leftarrow k + 1$ $test:$ $\ \lnot Q(X_{k-1})$

Кроме того, имеет место быть следствие – форма записи 8, у которой $test := 0$

Форма записи 8:

$\mathbf{do} $ $statement$ $\mathbf{while} ( 0 )$ Где $statement$: $\mathbf{if}$ ($\ \lnot I(X_k)$ ) $\mathbf{break}$ $X_k \leftarrow F(X_{k-1})$

В тоже время проверка на инвариантность производится, несмотря на то, что условие окончания цикла никогда не станет равным "ИСТИНА", потому что всегда равна "ЛОЖЬ". Таким образом доказано, что форма записи 8 является следствием формы записи 7.

Реализация цикла, как показано в форме записи 8, используется в GNU C для повторного обращения к только что выполненному коду и продвижения алгоритма цикла на уровне RTL.

В листинге 7 приведен пример реализации кода для формы записи 7:

Листинг 7:


 . . .
      *go_p = (char *)carray_buf;    /* указатель на начальное начальное состояние X[k] 
                                     для проверки инвариантности */


 int	i=0;

 . . .

 do {  
    if( isdigit( *go_p ) ) { 
      fprintf(stdout,"Step %d: '%c'(%02X)\n", i++, *go_p, *go_p++ );                                   
      *go_p  = *(go_p-1) + 1;
      continue;
    } 
    break;
 } while ( !ispunct ( *go_p ) ); /* проверка на условие окончания цикла и
                                                        истинность инварианта */

 fprintf(stdout,"Step %d: end of loop.\n", i );    

 . . .

из листинга 7 видно, что первым будет проверен инвариант, а уже затем условие на окончание цикла, но лишь после выполения присвоения новых значений $X_k$.

В листинге 7 приведен пример реализации кода для формы записи 7:

Листинг 7:


 . . .
      *go_p = (char *)carray_buf;    /* тоже самое как и в предыдущем примере */


 int	i=0;

 . . .

 do {  
    if( isdigit( *go_p ) ) { 
      fprintf(stdout,"Step %d: '%c'(%02X)\n", i++, *go_p, *go_p++ );                                   
      *go_p  = *(go_p-1) + 1;
    } 
 } while ( 0 ); /* проверка на условие окончания цикла отсутствует, т.к. 
                   она всегда равна "ЛОЖЬ" и выполнен будет лишь один проход
                   цикла */

 fprintf(stdout,"Step %d: end of loop.\n", i );    

 . . .

5. Библиография

См. п.п.13-19 в основной библиографии


К оглавлению