25
Шапорев Тимур Валентинович Приёмы программирования. Москва, 2013 Введение. They were called “Wonder Workers” by an observer who caught one of them wondering what kind of a wrench to use to hammer a screw into a brick. 1 George Brown, Congressman, San Bernardino, California Эпиграф в компактном виде содержит идею без малого половины этой книжки, то есть: для разных работ существуют разные приспособления и приёмы, и если вы не умеете ими пользоваться, то программа как раз и будет напоминать шуруп, забитый в кирпич (и считайте, что это ещё повезло: ввернуть гвоздь отвёрткой, скорее всего, просто не получится). Программирование оно большое, в этой области человеческой деятельности придумано довольно много, но нет ни одной придумки, которая одинаково хорошо подходила бы для любых случаев. (В частности, все попытки создания универсальных языков программирования закончились пониманием того, что такой язык вообще вряд ли возможен.) Здесь будет описано узкое подмножество большого программирования: те приёмы и придумки, которые соотносятся с моим личным опытом и в профессии, и в преподавании. 2 1 Примерный перевод: их назвал «изумительными рабочими» наблюдатель, который застал одного из них интересующимся, какого рода приспособление использовать, чтобы забить шуруп в кирпич. Цит. по Ken Arnold, James Gosling "The Java Programming Language" p.271 Addison-Wesley Publishing Company, Inc. Reading, Massachusetts, 1996 2 Интересующихся программированием в целом отсылаю к отличной книге: Н.Н.Непейвода «Стили и методы программирования», М.: Интернет Ун-т Информ. Технологий, 2005. Причины, по которым нет хороших универсальных языков программирования примерно те же, по которым изобретение шурупов не отменило гвоздей. А ведь есть ещё и клейВзыскующим более развёрнутый и наукообразный ответ рекомендую всё ту же книгу.

Приёмы программирования

Embed Size (px)

DESCRIPTION

Понятие надёжности. Легенда о потерянном "Вояджере". Метод рекурсивного спуска на примере арифметических выражений. Обработка матриц в языке C.

Citation preview

Page 1: Приёмы программирования

Шапорев Тимур Валентинович

Приёмы программирования. Москва, 2013

Введение. They were called “Wonder Workers” by an observer who caught one of

them wondering what kind of a wrench to use to hammer a screw into a brick.1

George Brown, Congressman, San Bernardino, California Эпиграф в компактном виде содержит идею без малого половины этой книжки, то есть:

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

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

1 Примерный перевод: их назвал «изумительными рабочими» наблюдатель, который застал одного из них

интересующимся, какого рода приспособление использовать, чтобы забить шуруп в кирпич. Цит. по Ken Arnold, James Gosling "The Java Programming Language" p.271 Addison-Wesley Publishing

Company, Inc. Reading, Massachusetts, 1996 2 Интересующихся программированием в целом отсылаю к отличной книге: Н.Н.Непейвода «Стили и

методы программирования», М.: Интернет Ун-т Информ. Технологий, 2005. Причины, по которым нет хороших универсальных языков программирования примерно те же, по которым

изобретение шурупов не отменило гвоздей. А ведь есть ещё и клей… Взыскующим более развёрнутый и наукообразный ответ рекомендую всё ту же книгу.

Page 2: Приёмы программирования

Понятие надёжности. — Обратите внимание: слева от вас биты с нулевого по седьмой

сумматора. Экскурсовод в музее вычислительной техники, Вашингтон.

…Когда деревья и компьютеры были большими… В 40-е годы XX века использовались вычислители, в которых даже один бит информации

был непростым устройством с габаритом метра полтора.3 Этого я, впрочем, помнить не могу, но вполне ещё помню, как БЭСМ-6 занимала три этажа в южном крыле Лабораторного корпуса, потребляла 360 киловатт, обслуживала её бригада техников, а уж чтоб чинить, нужны были инженеры… Западные компьютеры 60-х выглядели примерно так же, а вот аренда помещений и зарплаты уже и тогда были немаленькими. В общем, несложно представить, что каждая минута работы компьютера обходилась в конкретную денежку, а с пользователей брали деньги даже не за минуту, а за каждую секунду работы центрального процессора. Неудивительно, что в таких условиях первым и главным требованием к программам была эффективность: работать как можно быстрее и с минимумом накладных расходов. Из-за этого первые программы писали в основном на «машинном языке»4

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

Легенда о потерянном «Вояджере». Когда американцы готовили серию космических аппаратов «Вояджер», то программное

обеспечение для них писали в основном на FORTRAN IV (как тогда было модно). Разработчики языка ФОРТРАН позаботились об удобствах программистов…

Во-первых, в языке ФОРТРАН пробелы были «незначащие»: компилятор удалял пробелы и только потом анализировал текст программы, так что имя любимой подпрограммы M Y F A V O U R I T E S U B R O U T I N E можно написать так, чтоб издалека видно, GO TO можно писать по правилам английской грамматики, а не так, как нынче сложилось… мелочь, но приятно.

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

И вот сочетание этих двух удобств привело к тому, что… Где-то в блоке ориентации был кусочек кода, которому надо было итерационно, в цикле,

посчитать… в общем, нечто, относящееся к ориентации. Заголовок цикла в ФОРТРАНе выглядит примерно так: DO 157 I=1,100

То есть ключевое слово DO, номер метки, на которой заканчивается цикл, переменная цикла и, после знака равенства, через запятую диапазон значений, которые пробегает переменная цикла. Потом идут какие-то операторы — собственно тело цикла, и заканчивается цикл оператором с меткой, обычно нечто вроде: 157 CONTINUE

В заголовке цикла вместо запятой оказалась точка. (Если посмотреть на клавиатуру, то эти символы рядом — ошибиться легко, а разница «на глазок» мало заметна.) В результате диапазон значений превратился в константу с плавающей точкой («действительное» число), а вся строчка (выкиньте пробелы и посмотрите!) в оператор присваивания. Современный

3 Не знаю почему, хотя технические возможности тех времён вполне позволяли сделать ламповый триггер, и

было бы это во всех отношениях не хуже, но… 4 На Западе в основном использовали ассемблер, у нас применялись и другие системы кодирования, см.

например А.В.Кронрод «Беседы о программировании».

Page 3: Приёмы программирования

компилятор сказал бы, что знать не знает переменной DO157I, и ошибку сразу обнаружили бы, ну а старый ФОРТРАН молча завёл никому не нужную переменную и пошёл дальше… А поскольку один-то раз тело цикла выполнялось, то при наземных испытаниях код выдал правдоподобный результат, и ошибку не обнаружили до тех пор, пока где-то возле Сатурна аппарат не вышел на связь в очередной раз, и начались поиски, что же могло случиться.

Эта история наиболее популярная, но была она отнюдь не единственной. 4 июня 1996 года новая ракета Ariane 5 «гордость стран евросоюза» взорвалась через 40

секунд после старта. Цепочка причин и следствий, в результате которой сработала система автоподрыва, началась с классической «чайницкой» ошибки: переполнился буфер. Дело в том, что систему управления изначально разрабатывали для Ariane 4, и где-то в системе был заведён буфер для преобразования значения скорости. Размер буфера был выбран (для экономии памяти) так, чтобы поместилось максимальное значение скорости без запаса, а проверку на переполнение убрали для лучшего быстродействия и, опять же, экономии памяти. Максимальное значение скорости Ariane 5 стало больше… В итоге такая экономия обошлась довольно дорого: только научное оборудование на борту стоило примерно полмиллиарда долларов.

Немного меньше — в 475 миллионов — обошлась Intel (программная!) ошибка в микрокоде, из-за которой первый Pentium на операции деления с плавающей точкой ошибался примерно в пятом знаке. Хотя мало кому из пользователей действительно была нужна точность в этом месте, Intel был вынужден заменить все дефектные процессоры. Потери репутации Intel вряд ли удастся перевести в цифры.

Это всё были потери материальные… В 2000 году в Национальном институте рака в Панаме использовалась программа для

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

Эти и, увы, другие подобные эпизоды привели к пониманию того, что средства программирования должны в первую очередь заботиться о надёжности. Если программа делает чушь, то совсем не интересно ни как эффективно она эту чушь делает, ни насколько быстро такую программу написали. Как правило бывает лучше на начальном этапе заставить программиста напрячься дополнительно, но выразить свою мысль как можно более полно и точно, чтобы избежать ошибок потом.

Page 4: Приёмы программирования

Метод рекурсивного спуска на примере арифметических выражений.5

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

Подбираться к этому методу я буду постепенно и издалека. Важность средств обработки текстовой информации понята давно, и ещё в 60-х годах был

придуман специальный язык SNOBOL6, замечательный несколькими идеями, например, там была изумительная структура управления — лучшая (и единственная мне известная) метафора конечного автомата, то есть инструкции программы состояли из трёх частей: образца для сравнения и двух меток. Если текст входных данных подходил под образец, то управление передавалось на первую метку, ну а иначе на вторую, и таким образом можно было сделать программу в стиле спагетти восхитительно непонятной. Но для сегодняшней темы важнее идея образца для сравнения. Эта идея потом развилась дальше и сегодня модно образцы описывать так называемыми регулярными выражениями.

Регулярных выражений существует много разных вариантов, поскольку они нынче в моде: встроены в языки JavaScript или Tcl, язык Perl просто-таки на них основан. Для языка C существует библиотека функций, которые работают с регулярными выражениями (хотя запрограммировать регулярные выражения самостоятельно — полезное упражнение, на котором есть чему научиться). Ниже описан минимальный набор возможностей регулярных выражений.

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

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

Десятичная целая константа в смысле языка C описывается как [1-9][0-9]* Имя в смысле C — это [A-Z_a-z][0-9A-Z_a-z]* В регулярных выражениях символ ^ означает начало строки, а символ $ — конец строки.

Соответственно пустая строка — это ^$, а ^ *$ — невидимая строка. Несмотря на всё своеобразие редактора vi7, ему можно многое простить за возможность

использования регулярных выражений. Например, если в программу добавлена пара фигурных скобок, и их содержимое надо

сдвинуть вправо, то это можно сделать командой :s/^/ /, обратную операцию командой :s/^ //.

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

5 Вообще-то теория реализации языков программирования — это отдельный институтский курс, и эта

единственная глава его не заменит, так что если есть интерес, то лучше обратиться к упомянутому курсу. 6 Р.Грисуолд, Дж.Поудж, И.Полонски «Язык программирования СНОБОЛ-4», М.: «Мир», 1980. Ну нравятся

мне старые языки — такое там всё было своеобразное… 7 Опять-таки, редактор vi изучался в третьем семестре.

Page 5: Приёмы программирования

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

Для описания сложных конструкций вроде синтаксиса языков программирования обычно используется формализм Бэкуса-Наура, не такой краткий, зато (обычно) более понятный.

Формализм этот нужен не сам по себе, мне в этой главе он потребуется как подсобное средство в разборе арифметических выражений методом рекурсивного спуска. Чтобы легче освоиться с идеей однопроходного анализа арифметических выражений или каких-нибудь других скобочных выражений, вот ещё одна учебно-тренировочная задача. Пусть имеется строчка с однотипными скобками, например только круглыми. Как проверить, что скобки расставлены «правильно»? Подумайте несколько минут, прежде чем читать ответ. Способ следующий: заводим переменную, которая в начале имеет нулевое значение, и просматриваем строку по символу: если встречается открывающая скобка, то прибавляем единичку, если закрывающая, то вычитаем. Скобки расставлены «правильно», если переменная никогда не становится отрицательной, и в конце строки её значение опять становится нулём.

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

Итак, формализм Бэкуса-Наура... Вообще говоря, существует довольно много вариантов, самый простой (хотя и не самый удобный) использует единственный особенный символ: символ определения традиционно выглядит как ::=. Например:

<цифра> ::= 0 то есть ноль — это цифра.

<цифра> ::= 1 единица — это тоже цифра. И так далее до

<цифра> ::= 9 (В языках C или Java этому набору соответствовало бы предписание switch.) Аналогичным образом можно определить понятие буквы: <буква> ::= A …и так далее… <буква> ::= z Естественно, перечислять построчно все 53 буквы как-то не очень интересно, поэтому на

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

Теперь можно написать определение целочисленной константы (в «общеупотребитель-ном» смысле; в языках C или Java целые константы устроены более сложно, и соответствующий набор определений останется для самостоятельного упражнения.)

<число> ::= <цифра> <число> ::= <число><цифра>

То есть одиночная цифра — это число, и если к числу приписать цифру, тоже получится число.8

Идентификатор в смысле языка программирования определяется ненамного сложнее: <имя> ::= <буква> <имя> ::= <имя><буква> <имя> ::= <имя><цифра> Теперь из этих кирпичиков можно строить арифметическое выражение. Поскольку я

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

<множитель> ::= <число> 8 А можно ли приписывать цифры не справа, а слева? Можно, но будет хуже. Почему? Вот и повод

заглянуть в более подробный учебник, например И.Л.Братчиков «Синтаксис языков программирования», М.: «Наука», 1975.

Page 6: Приёмы программирования

<множитель> ::= <имя> И соответственно <произведение> ::= <множитель> <произведение> ::= <произведение>*<множитель> <произведение> ::= <произведение>/<множитель> Поскольку умножение и деление имеют одинаковый приоритет, то нет большого смысла

вводить отдельное определение для деления, но вот название «множитель» оказывается не совсем удачным, а если бы потребовалась, например, операция возведения в степень, то оно оказалось бы и совсем неудачным, поэтому традиционно используются названия «атом», «терм» и им подобные... Но я надеюсь, что так пример будет всё же понятней.

Поскольку, опять же, рассматриваются только четыре действия арифметики, то выражение — это сумма... ну или, с учётом вычитания, «типа сумма»:

<выражение> ::= <произведение> <выражение> ::= <выражение>+<произведение> <выражение> ::= <выражение>-<произведение> Чего не хватает в этом наборе определений? (С чего я начинал?) Конечно, не хватает

скобок. То есть к набору правил <множитель> ::= <число> <множитель> ::= <имя> Надо добавить <множитель> ::= ( <выражение> ) И определение закончено. Существуют программы, которые по такого рода формальным грамматикам

автомагически создают программный код для анализа выражений — самая известная это наверное YACC — но частенько использование таких программ — это, что называется, стрельба из пушки по воробьям. Предлагаю на примере разобрать, как сформулированная выше грамматика превращается в код на языке C или Java. Пример компилирует арифметическое выражение в ассемблерные команды «с плавающей точкой» Intel-евского семейства.

Page 7: Приёмы программирования

Начнём с самой простой заготовки #include <stdio.h> int main() { return 0; }

import java.io.*; class FCompile extends PushbackInputStream { public static void main(String[] args) { } }

И будем её постепенно заполнять. Для запоминания текстовых данных по мере необходимости в Java удобно пользоваться классом StringBuffer. В С можно сделать

аналогичное устройство, но сейчас у нас другая задача, поэтому будем считать, что имена и константы не могут быть длиннее 255 символов и заведём для них глобальный буфер со счётчиком. static int nch = 0; static char cbuf[256]; static void savech(int c) { if (nch < sizeof(cbuf)-1) cbuf[nch++] = (char)c; }

У такого решения через глобальные статические переменные масса недостатков, но это самое простое решение, которое я знаю. Следующий кусочек кода предназначен для подсчёта позиции в строке для печати сообщения об ошибке (ну и для обработки DOS-овских

концов строк), вместо него можно было бы оставить стандартные getchar()/ungetc() в C или read()/unread() в Java, так что в первом приближении можно в нём не разбираться. static int charno = 0, lastchar = EOF; int charno = 0, lastchar = -1; static int getch() { register int c = fgetc(stdin); if ('\r' == c) { if ('\n' != (c = fgetc(stdin))) { if (EOF != c) (void)ungetc(c, stdin); c = '\r'; } } if ('\n' != c) { if ('\n' == lastchar) charno = 0; else ++charno; } return lastchar = c; }

public int read() throws IOException { int c = super.read(); if (c == '\r') { // DOS specifics if ((c = super.read()) != '\n') { if (c != EOF) super.unread(c); c = '\r'; } } if (c != '\n') { if (lastchar == '\n') charno = 0; else ++charno; } lastchar = c; return c; }

static void ungetch(int c) { if (EOF != c) { --charno; (void)ungetc(c, stdin); } }

public void unread(int c) throws IOException { if (c != EOF) { --charno; super.unread(c); } }

Page 8: Приёмы программирования

Приступим к содержательной части. Заведём набор констант, чтобы функции-методы могли сообщать, что именно они обнаружили: имя, число, или вообще ошибку.

#define ERROR (-1) #define NONE 0 #define NUMBER 1 #define NAME 2 #define EXPRESSION 3 #define ATOM(x) (NUMBER == (x) || NAME == (x))

final static int ERROR = -1, NONE = 0, NUMBER = 1, NAME = 2, EXPRESSION = 3; boolean atom(int x) { return x == NUMBER || x == NAME; }

Проверим, является ли входной символ цифрой или буквой (здесь могли бы быть длинные switch-и, но можно короче). #define IS_DIGIT(c) ((c) >= '0' && (c) <= '9') static int is_letter(register int c) { return (c>='A' && c<='Z')||(c>='a' && c<='z')||c=='_'; }

boolean isDigit(int c) { return c>='0' && c<='9'; } boolean isLetter(int c) { return c>='A' && c<='Z' || c>='a' && c<='z' || c=='_'; }

Если посмотреть хоть бы и на уже написанные кусочки, то можно вспомнить, что бывает удобно добавлять в текст программы пробелы. Обычно такая ситуация решается не на уровне грамматики, а «этажом ниже», на уровне лексического анализа, так что внутри грамматики их как бы и нет. static int skip_whitespaces(void) { register int c; do c = getch(); while (' ' == c || '\t' == c); return c; }

int skipWhitespaces() throws IOException { int c; do c = read(); while (c == ' ' || c == '\t'); return c; }

Проанализируем элемент выражения: возьмём первый значащий (отличный от пробела) символ. Если это цифра или знак минус, то дальше должна быть константа, если буква, то имя, если открывающая скобка, то выражение в скобках, а иначе это ошибка. static int term(void) { register int c = skip_whitespaces(); if (IS_DIGIT(c) || '-' == c) { return NUMBER; } if (is_letter(c)) { return NAME; } if ('(' == c) { } return '\n' == c ? NONE : ERROR; }

int term(StringBuffer buf) throws IOException { int c = skipWhitespaces(); if (isDigit(c) || c == '-') { return NUMBER; } if (isLetter(c)) { return NAME; } if (c == '(') { } return c == '\n' ? NONE : ERROR; }

Это, разумеется, только схема, которую надо наполнить. Анализ и запоминание имени может выглядеть например так:

Page 9: Приёмы программирования

if (is_letter(c)) { do { savech(c); c = getch(); } while (is_letter(c) || IS_DIGIT(c)); ungetch(c); return NAME; }

if (isLetter(c)) { do { buf.append((char)c); c = read(); } while (isLetter(c) || isDigit(c)); unread(c); return NAME; }

А вот пример анализа константы. В дополнение к исходной грамматике я разобрал на части «действительную» константу if (IS_DIGIT(c) || '-' == c) { int m, n; if ('-' == c) { savech(c); c = getch(); }

if (isDigit(c) || c == '-') { if (c == '-') { buf.append('-'); c = read(); }

for (n=0; IS_DIGIT(c); ++n, c=getch()) savech(c); m = 0; if ('.' == c) { savech(c); c = getch(); for (; IS_DIGIT(c); ++m, c=getch()) savech(c); } if (!n && !m) return ERROR;

int fractionLength = 0, integerLength = buf.length(); for (; isDigit(c); c = read()) buf.append((char)c); integerLength = buf.length() - integerLength; if (c == '.') { buf.append('.'); fractionLength = buf.length(); while (isDigit(c = read())) buf.append((char)c); fractionLength = buf.length() - fractionLength; } if (0 >= integerLength && 0 >= fractionLength) return ERROR;

if ('E' == c || 'e' == c) { savech(c); c = getch(); if ('+' == c || '-' == c) { savech(c); c = getch(); } for (n=0; IS_DIGIT(c); ++n, c=getch()) savech(c); if (!n) return ERROR; } ungetch(c); return NUMBER; }

if (c == 'E' || c == 'e') { buf.append((char)c); c = read(); if (c == '-' || c == '+') { buf.append((char)c); c = read(); } int xLength = buf.length(); for (; isDigit(c); c = read()) buf.append((char)c); if (xLength >= buf.length()) return ERROR; } unread(c); return NUMBER; }

Анализ скобочного выражения пусть пока останется незаполненным. Из таких элементов можно построить «произведение»:

Page 10: Приёмы программирования

static int product(void) { register int op, status; status = term(); /* первый множитель */ if (NONE >= status) return ERROR; /* первый множитель обработаем иначе, чем другие */ op = skip_whitespaces(); if ('*' != op && '/' != op) { ungetch(op); return status; } fload(status); while ('*' == op || '/' == op) { if (NONE >= (status = term())) return ERROR; fcode(status, ('/' == op ? "div" : "mul")); op = skip_whitespaces(); } ungetch(op); return EXPRESSION; }

int product(StringBuffer buf) throws IOException { int status = term(buf); // первый множитель if (NONE >= status) return ERROR; // первый множитель обработаем иначе, чем другие int op = skipWhitespaces(); if (op != '*' && op != '/') { unread(op); return status; } fload(status, buf); while (op == '*' || op == '/') { if (NONE >= (status = term(buf))) return ERROR; fcode(status, (op == '/' ? "div" : "mul"), buf); op = skipWhitespaces(); } unread(op); return EXPRESSION; }

Здесь fload() и fcode() — заготовки для обработки данных. Если писать калькулятор, то это были бы вычисления, а у нас тут будет кодогенерация. static void fload(int status) { if (ATOM(status)) printf("\tfld\t%.*s\n", nch, cbuf); }

void fload(int status, StringBuffer value) { if (atom(status)) { System.out.print("\tfld\t"); System.out.println(value); } }

static void fcode(int status, char *code) { printf("\tf%s", code); if (ATOM(status)) printf("\t%.*s", nch, cbuf); printf("\n"); }

void fcode(int status, String code, StringBuffer value) { System.out.print("\tf"); System.out.print(code); if (atom(status)) { System.out.print('\t'); System.out.print(value); } System.out.println(); }

Анализ «суммы» очень похож на «произведение»:

Page 11: Приёмы программирования

static int sum(void) { register int op, status = product(); if (NONE >= status) return ERROR; fload(status); while (op=skip_whitespaces(), '+' == op || '-' == op) { if (NONE >= (status = product())) return ERROR; fcode(status, ('-' == op ? "sub" : "add")); } ungetch(op); return EXPRESSION; }

int sum(StringBuffer buf) throws IOException { int status = product(buf); if (NONE >= status) return ERROR; fload(status, buf); int op; while ((op = skipWhitespaces()) == '+' || op == '-') { if (NONE >= (status = product(buf))) return ERROR; fcode(status, ('-' == op ? "sub" : "add"), buf); } unread(op); return EXPRESSION; }

Теперь можно вернуться к началу и заполнить кусочек, работающий со скобками: if ('(' == c) { int status = sum(); if (NONE >= status) return ERROR; if (')' != (c = skip_whitespaces())) return ERROR; return EXPRESSION; }

if (c == '(') { int status = sum(buf); if (NONE >= status) return ERROR; if ((c = skipWhitespaces()) != ')') return ERROR; return EXPRESSION; }

Наконец, чтобы получить цельную программу и посмотреть как это всё работает, надо написать «вызывалочку»: void compile(void) { register int c; int status = sum(); int pos = charno; if (ERROR == status || (c=skip_whitespaces(), EOF!=c && '\n'!=c))) { printf("%*s^\n", pos, ""); printf("Error at position %d:\n", pos); while (EOF != c) c = fgetc(stdin); } }

void compile() throws IOException { StringBuffer buf = new StringBuffer(); int status = sum(buf); int pos = charno; int c; if (status == ERROR || ((c=skipWhitespaces()) != '\n' && c != EOF)) { for (int n=pos; n>0; --n) System.out.print(' '); System.out.println('^'); System.out.print("Error at position "); System.out.println(pos); while (available() > 0) read(); } }

На следующих страницах все эти детали собраны в цельные программы, теперь уже не параллельно, а последовательно C-шную и Java.

Page 12: Приёмы программирования

#include <stdio.h> static int charno = 0, lastchar = EOF; static int getch() { register int c = fgetc(stdin); if ('\r' == c) { if ('\n' != (c = fgetc(stdin))) { if (EOF != c) (void)ungetc(c, stdin); c = '\r'; } } if ('\n' != c) { if ('\n' == lastchar) charno = 0; else ++charno; } return lastchar = c; } static void ungetch(int c) { if (EOF != c) { --charno; (void)ungetc(c, stdin); } } static int nch = 0; static char cbuf[256]; static void savech(int c) { if (nch < sizeof(cbuf)-1) cbuf[nch++] = (char)c; } #define ERROR (-1) #define NONE 0 #define NUMBER 1 #define NAME 2 #define EXPRESSION 3 #define ATOM(x) (NUMBER == (x) || NAME == (x)) #define IS_DIGIT(c) ((c) >= '0' && (c) <= '9') static int is_letter(register int c) { return (c>='A' && c<='Z') || (c>='a' && c<='z') || c == '_'; } static int skip_whitespaces(void) { register int c; do c = getch(); while (' ' == c || '\t' == c); return c; } static int sum(void); static int term(void) { int m, n; register int c = skip_whitespaces(); nch = 0; if (IS_DIGIT(c) || '-' == c) { if ('-' == c) { savech(c); c = getch(); } for (n=0; IS_DIGIT(c); ++n, c=getch()) savech(c); m = 0;

Page 13: Приёмы программирования

if ('.' == c) { savech(c); c = getch(); for (; IS_DIGIT(c); ++m, c=getch()) savech(c); } if (!n && !m) return ERROR; if ('E' == c || 'e' == c) { savech(c); c = getch(); if ('+' == c || '-' == c) { savech(c); c = getch(); } for (n=0; IS_DIGIT(c); ++n, c=getch()) savech(c); if (!n) return ERROR; } ungetch(c); return NUMBER; } if (is_letter(c)) { do {/* search for the end of varibale name */ savech(c); c = getch(); } while (is_letter(c) || IS_DIGIT(c)); ungetch(c); return NAME; } if ('(' == c) { int status = sum(); if (NONE >= status) return ERROR; if (')' != (c = skip_whitespaces())) return ERROR; return EXPRESSION; } return '\n' == c ? NONE : ERROR; } static void fload(int status) { if (ATOM(status)) printf("\tfld\t%.*s\n", nch, cbuf); } static void fcode(int status, char *code) { printf("\tf%s", code); if (ATOM(status)) printf("\t%.*s", nch, cbuf); printf("\n"); } static int product(void) { register int op, status; status = term(); /* parse the 1st multiplicand */ if (NONE >= status) return ERROR; /* special handling for the 1st multiplicand */ op = skip_whitespaces(); if ('*' != op && '/' != op) { ungetch(op); return status; } fload(status); while ('*' == op || '/' == op) { if (NONE >= (status = term())) return ERROR; fcode(status, ('/' == op ? "div" : "mul")); op = skip_whitespaces(); }

Page 14: Приёмы программирования

ungetch(op); return EXPRESSION; } static int sum(void) { register int op, status = product(); if (NONE >= status) return ERROR; fload(status); while (op = skip_whitespaces(), '+' == op || '-' == op) { if (NONE >= (status = product())) return ERROR; fcode(status, ('-' == op ? "sub" : "add")); } ungetch(op); return EXPRESSION; } void compile(void) { register int c; int status = sum(); int pos = charno; if (ERROR == status || (c=skip_whitespaces(), EOF!=c && '\n'!=c)) { printf("%*s^\n", pos, ""); printf("Error at position %d:\n", pos); while (EOF != c) c = fgetc(stdin); } } int main() { register int c; while (c = skip_whitespaces(), EOF!=c && '\n'!=c) { ungetch(c); compile(); } return 0; } import java.io.*; class FCompile extends PushbackInputStream { static final int EOF = -1; int charno = 0, lastchar = -1; FCompile(InputStream in) { super(in); charno = 0; lastchar = EOF; } public int read() throws IOException { int c = super.read(); if (c == '\r') { // DOS specifics if ((c = super.read()) != '\n') { if (c != EOF) super.unread(c); c = '\r'; } } if (c != '\n') { if (lastchar == '\n') charno = 0; else ++charno; } lastchar = c; return c; }

Page 15: Приёмы программирования

public void unread(int c) throws IOException { if (c != EOF) { --charno; super.unread(c); } } final static int ERROR = -1, NONE = 0, NUMBER = 1, NAME = 2, EXPRESSION = 3; boolean atom(int x) { return x == NUMBER || x == NAME; } boolean isDigit(int c) { return c>='0' && c<='9'; } boolean isLetter(int c) { return c>='A' && c<='Z' || c>='a' && c<='z' || c=='_'; } int skipWhitespaces() throws IOException { int c; do c = read(); while (c == ' ' || c == '\t'); return c; } int term(StringBuffer buf) throws IOException { int c = skipWhitespaces(); buf.setLength(0); if (isDigit(c) || c == '-') { if (c == '-') { buf.append('-'); c = read(); } int fractionLength = 0, integerLength = buf.length(); for (; isDigit(c); c = read()) buf.append((char)c); integerLength = buf.length() - integerLength; if (c == '.') { buf.append('.'); fractionLength = buf.length(); while (isDigit(c = read())) buf.append((char)c); fractionLength = buf.length() - fractionLength; } if (0 >= integerLength && 0 >= fractionLength) return ERROR; if (c == 'E' || c == 'e') { buf.append((char)c); c = read(); if (c == '-' || c == '+') { buf.append((char)c); c = read(); } int xLength = buf.length(); for (; isDigit(c); c = read()) buf.append((char)c); if (xLength >= buf.length()) return ERROR; } unread(c); return NUMBER; } if (isLetter(c)) { do { buf.append((char)c); c = read();

Page 16: Приёмы программирования

} while (isLetter(c)); unread(c); return NAME; } if (c == '(') { int status = sum(buf); if (NONE >= status) return ERROR; if ((c = skipWhitespaces()) != ')') return ERROR; return EXPRESSION; } return c == '\n' ? NONE : ERROR; } void fload(int status, StringBuffer value) { if (atom(status)) { System.out.print("\tfld\t"); System.out.println(value); } } void fcode(int status, String code, StringBuffer value) { System.out.print("\tf"); System.out.print(code); if (atom(status)) { System.out.print('\t'); System.out.print(value); } System.out.println(); } int product(StringBuffer buf) throws IOException { int status = term(buf); // parse the 1st multiplicand if (NONE >= status) return ERROR; // special handling for the 1st multiplicand int op = skipWhitespaces(); if (op != '*' && op != '/') { unread(op); return status; } fload(status, buf); while (op == '*' || op == '/') { if (NONE >= (status = term(buf))) return ERROR; fcode(status, (op == '/' ? "div" : "mul"), buf); op = skipWhitespaces(); } unread(op); return EXPRESSION; } int sum(StringBuffer buf) throws IOException { int status = product(buf); if (NONE >= status) return ERROR; fload(status, buf); int op; while ((op = skipWhitespaces()) == '+' || op == '-') { if (NONE >= (status = product(buf))) return ERROR; fcode(status, ('-' == op ? "sub" : "add"), buf); } unread(op); return EXPRESSION; }

Page 17: Приёмы программирования

void compile() throws IOException { StringBuffer buf = new StringBuffer(); int status = sum(buf); int pos = charno; int c; if (status == ERROR || ((c=skipWhitespaces()) != '\n' && c != EOF)) { for (int n=pos; n>0; --n) System.out.print(' '); System.out.println('^'); System.out.print("Error at position "); System.out.println(pos); while (available() > 0) read(); } } public static void main(String[] args) throws IOException { FCompile sample = new FCompile(System.in); for (;;) { int c = sample.skipWhitespaces(); // stop on empty string or eof if (c == '\n' || c == EOF) break; sample.unread(c); sample.compile(); } } }

Page 18: Приёмы программирования

Упражнения: 1. Найдите ту рекурсию, что дала имя методу. 2. Приведённый пример не обрабатывает как минимум одну ситуацию, допустимую в

«традиционных» арифметических выражениях. Что это за ситуация? Как надо изменить грамматику и программу, чтобы её обработать?

3. Напишите (перепишите обратно из кода программы) грамматику для «действительных чисел с плавающей точкой».

4. Добавьте операцию возведения в степень. (Начните с вопроса, как надо изменить грамматику.)

5. Добавьте функции одного аргумента, например тригонометрические. (Начните, опять же, с грамматики.)

6. Переделайте компилятор в калькулятор: посчитайте результат выражения.

Page 19: Приёмы программирования

Обработка матриц в языке C. С точки зрения большинства современных языков программирования матрица — всего

лишь двумерный массив: double a[3][3], b[7][8], c[13][157];

Какие могут быть подвохи при работе с такими простыми объектами? Пусть нам нужно матрицы, например, перемножать. Умножение матриц — тоже вещь не самая сложная, хотя я и не мог запомнить правило,

пока мне не объяснили, что это частный случай свёртки тензоров по внутреннему индексу, то есть, если матрица C — это произведение матриц A и B, то её элементы

cij = ∑aikbkjЭта конструкция легко переписывается на C. Если, например, у нас есть матрицы

double x[3][3], y[3][3], z[3][3]; то программа их умножения могла бы выглядеть примерно так:

#define N 3 void matmul(double A[N][N], B[N][N], C[N][N]) { register int i, j, k; for (i=0; i<N; i++) for (j=0; j<N; j++) { register double s; /* сумма */ for (s=0.0, k=0; k<N; k++) s += A[i][k]*B[k][j]; C[i][j] = s; } }

Теперь пусть у нас есть два набора матриц для перемножения: double x[3][3], y[3][3], z[3][3]; double u[4][4], v[4][4], w[4][4];

Тут константой N не отделаться, естественно было бы написать: void matmul(int n, double A[n][n], B[n][n], C[n][n]) { register int i, j, k; for (i=0; i<n; i++) for (j=0; j<n; j++) { register double s; /* сумма */ for (s=0.0, k=0; k<n; k++) s += A[i][k]*B[k][j]; C[i][j] = s; } }

А не тут-то было! Стандарт языка C требует, чтобы размер массива был константой9. Ещё запутанней становится ситуация, если надо написать программу для библиотеки матричных операций общего назначения — там в принципе нельзя знать заранее, какой будет размер.

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

9 Разрешить массив переменного размера в языке C именно для таких случаев было бы не так уж сложно,

тем более, что массивы переменного размера в параметрах подпрограмм были сделаны аж ещё в FORTRAN-е 50-х годов. Говорит это не столько о том, что C — плохой язык вообще, сколько о том, что он не предназначен для задач математической физики и матричной алгебры в частности. Увы, лучшего выбора частенько нету. Впрочем, компилятор GNU C как раз поддерживает массивы переменного размера (в подходящих случаях, и в параметрах функции в частности), так что если есть возможность использовать GNU C и позабыть про другие компиляторы — пользуйтесь, не стесняйтесь!

Page 20: Приёмы программирования

Такую структуру можно создать кодом вроде следующего:

register int i; register double **a = (double**)malloc(n*sizeof(double*)); if (NULL == a) { /* обработать ошибку */ } for (i=0; i<n; ++i) { if (NULL == (a[i] = (double*)malloc(n*sizeof(double))) { /* обработать ошибку */ } }

Удобство такой организации в том, что ссылка на матрицу имеет тип double** для матрицы любого размера (даже не обязательно квадратной), а обращение к элементу матрицы — a[i][j] (как и раньше). Но это даётся далеко не даром.

Дело в том, что malloc() — довольно непростая функция. Для её работы нужны списки свободной и занятой памяти, при каждом обращении malloc просматривает эти списки, чтобы найти подходящий свободный кусок и запомнить, что он теперь занят, а если такого куска нет, то malloc ныряет в ядро операционной системы — обращается к диспетчеру памяти, чтобы добавить процессу странички памяти, потом включает новую память в список… То есть, во-первых, это довольно долго (особенно если сравнить с тем, что память автоматическим переменным на входе в функцию отводится единственным изменением регистра указателя стека всем сразу.) Во-вторых, на каждый выделенный кусочек памяти расходуется ещё сколько-то памяти на невидимую «служебную» информацию (обычно это 30-40 байт, в разных реализациях по-разному). Так что при работе с небольшими матрицами накладные расходы (хоть по быстродействию, хоть по памяти) могут в разы превосходить полезную работу или данные.

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

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

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

Если расстояния между соседними точками «достаточно» малы, то приближение будет «достаточно» точным. Что такое «достаточно» в каждом конкретном случае — это, если и не самый главный вопрос вычислительной математики, то уж точно один из, так что ответ на него

Page 21: Приёмы программирования

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

распределение температуры в прямоугольной пластинке. Поделим пластинку на «достаточно» мелкие клетки. Уравнение теплопроводности имеет первый порядок по времени и второй в пространстве, так что, если известны значения температуры в трёх соседних клетках вдоль и поперёк, то можно аппроксимировать вторые производные, подставить их в уравнение теплопроводности, чтобы вычислить производную по времени, и получить приблизительное значение температуры на следующем временном шаге, то есть спустя «достаточно» малый промежуток времени (см. рис.)

Если в таком расчёте использовать «монолитные» матрицы, то требуется двойной объём

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

А вот если использовать массив указателей на строки, то можно заметить, что, после того,

как вычислены «будущие» значения во второй строке, данные из первой строки (текущего момента) больше в расчёте не потребуются, и можно эту строчку утилизировать.

Page 22: Приёмы программирования

То есть можно в начале шага не заполнять верхнюю матрицу целиком, а захватить только

две строчки «для разгона», и освободившиеся строчки передавать «сверху вниз» (на рисунке показано двойным зелёным штрихом). Понятно, что на больших матрицах выигрыш по памяти будет почти вдвое.

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

Универсальный способ доступа к элементам матрицы основан на том, что компьютерная память одномерна; то есть какая бы сложная структура ни была написана в языке C, на нижнем уровне она всё равно превращается в линейную последовательность машинных слов или байт. Например, всё та же матрица 3×3 располагается в памяти так10:

a00 a01 a02 a10 a11 a12 a20 a21 a22То есть, если есть указатель Aptr на начало матрицы размера N, то для доступа к i,j-тому

элементу можно использовать выражение Aptr[i*N+j], и программа перемножения монолитных матриц могла бы выглядеть так: void matmul(int n, double *Aptr, *Bptr, *Cptr) { register int i, j, k; for (i=0; i<n; i++) for (j=0; j<n; j++) { register double s; /* сумма */ for (s=0.0, k=0; k<n; k++) s += Aptr[i*n+k]*Bptr[k*n+j]; Cptr[i*n+j] = s; } }

Или даже так — нотация приближена к математической, но код в результате получится ровно тот же самый, так что выберите вариант, который больше нравится: void matmul(int n, double *Aptr, *Bptr, *Cptr) { # define A(y,x) Aptr[(y)*n+(x)] # define B(y,x) Bptr[(y)*n+(x)]

10 Осторожно! В других языках программирования это может быть по-другому. Например в FORTRAN-е

матрицы размещаются по столбцам, а не по строкам.

Page 23: Приёмы программирования

# define C(y,x) Cptr[(y)*n+(x)] register int i, j, k; for (i=0; i<n; i++) for (j=0; j<n; j++) { register double s; /* сумма */ for (s=0.0, k=0; k<n; k++) s += A(i,k)*B(k,j); C(i,j) = s; } # undef C # undef B # undef A }

Для проверки работоспособности этого кусочка кода потребуется «вызывалочка», например такая: #include <stdio.h> #ifndef N # define N 3 #endif void mdump(double m[N][N]) { register int i, j; for (i=0; i<N; i++) { for (j=0; j<N; j++) printf(" %.3f", m[i][j]); printf("\n"); } } void mget(double m[N][N], char *name) { register int i, j; for (i=0; i<N; i++) { for (j=0; j<N; j++) { printf("Enter %s[%d,%d]: ",name,i,j); scanf("%lf", &(m[i][j])); } } } main() { double a[N][N], b[N][N], c[N][N]; printf("Test %dx%d matrix multiplication\n",N,N); printf("Enter the 1st matrix:\n"); mget(a, "A"); printf("Enter the 2nd matrix:\n"); mget(b, "B"); printf("1st matrix:\n"); mdump(a); printf("2nd matrix:\n"); mdump(b); matmul(N, &a[0][0], &b[0][0], &c[0][0]); printf("Result:\n"); mdump(c); }

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

Можно ли сделать ещё лучше саму программу умножения? Конечно можно, а как же. Если, например, озаботиться быстродействием, то можно обратить внимание, что для

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

Page 24: Приёмы программирования

от этой операции никуда не деться, но вот именно в программе перемножения матриц можно заметить, что элементы выбираются из памяти не абы как, а с постоянным шагом: для столбцов шаг равен размеру матрицы, а для строк просто подряд. Соответственно оптимизированный вариант мог бы выглядеть например так: void matmul(int n, double *Aptr, double *Bptr, double *Cptr) { register int i, j, k; register double s; register double *Ai, *Bkj; for (Ai=Aptr, i=0; i<n; Ai+=n, i++) { for (j=0; j<n; j++) { Bkj = Bptr + j; for (s=0.0, k=0; k<n; Bkj += n, k++) s += Ai[k] * *Bkj; /* C(i,j) = s; */ *Cptr++ = s; } } }

Этот пример хорош тем, что должен работать на любой стандартной реализации C. А вот если пойти дальше, то можно заметить, что, хотя вспомогательная переменная для

суммы и описана как register double s, но у 16- или 32-битовых процессоров просто нет подходящих регистров (да и в случае 64-битовых ситуация не вполне однозначная), так что скорее всего переменная останется в оперативной памяти, зато на ассемблере Intel можно использовать стек арифметического устройства и ещё ускорить программу. Ассемблерное решение однако настолько сильно привязано к конкретному типу процессора, что в современных условиях бывает оправдано только в исключительных случаях. (И вообще связь с ассемблером выходит за рамки этого пособия, так что за соответствующим примером, если интересно, обратитесь к пособию за II семестр.)

Компромиссные подходы. Не стоит думать, будто между «монолитным» и «развесистым» подходами лежит

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

double a[3][3],*ap[3]; ap[0] = a[0]; ap[1] = a[1]; ap[2] = a[2]; создаёт «развесистую» структуру безо всякого malloc-а, если у вас есть готовая, кем-то

другим написанная матричная функция, которая требует «развесистой» структуры, то можно попробовать её удовлетворить именно таким способом.

Другой практически интересный трюк — захватить матрицу malloc-ом за один приём, включая как место под числовые данные, так и вспомогательный столбец указателей. (Основную трудность при этом представляет аккуратное выравнивание указателей на числовые данные, нужное из-за того, что sizeof(double*) < sizeof(double).)

Следующий пример мимикрирует под объектно-ориентированный стиль. (Юмор ситуации в том, что классы C++ таких трюков не позволяют: то есть завернуть матрицу в класс конечно можно, но единственным выделением памяти никак не обойдёшься.)

Определение (псевдо)класса — пусть, смеху ради, матрица будет прямоугольная: typedef struct { unsigned int m, n; double *data[1]; } Matrix;

Если есть указатель p на экземпляр такого «объекта», то обращение к элементу матрицы будет иметь вид p->data[i][j]. Чтобы это работало, память надо захватывать с большим запасом (но без излишков) — чтобы хватило и на сами данные, и на вспомогательный вектор указателей. Создание таких объектов — штука не совсем тривиальная, поэтому имеет смысл один раз написать функцию-конструктор, которая получает размеры матрицы и возвращает вновь созданный объект:

Page 25: Приёмы программирования

Matrix *newMatrix(unsigned m, unsigned n) { register Matrix *p; /* результат */ register size_t hsize; /* размер заголовка */ /* Расстояние от начала структуры до поля data НЕЛЬЗЯ считать как 2*sizeof(int), * потому что компилятор может добавить в структуру пустышки. * Вот почему расчёт размера начинается с... */ hsize = (char*)&(((Matrix*)NULL)->data[0]) – (char*)((Matrix*)NULL); /* Теперь добавим размер вспомогательного вектора указателей */ hsize += m*sizeof(double*); /* ...и пересчитаем байты в штуки double с выравниванием. * Обратите внимание, как целочисленное деление с округлением «вниз» * превращается в деление с округлением «вверх». */ hsize = (hsize + (sizeof(double)-1)) / sizeof(double); p = (Matrix*)malloc((hsize + m*n)*sizeof(double)); if (NULL != p) { /* Обработку возможной ошибки пока отложим. */ register unsigned i; register double *dP = (double*)p; for (dP+=hsize, i=0; i<m; dP+=n, ++i) p->data[i] = dP; p->m = m; p->n = n; } return p; }

Контрольные вопросы 1. Напишите выражение для доступа к элементу трёхмерного тензора (например такого

A[N][N][N]). Что если бы тензор имел 4 или 5 измерений? Можно ли получившиеся выражения улучшить? (Подсказка: говорят ли Вам что-нибудь слова «схема Горнера»?)

2. Нарисуйте схему распределения памяти в матричном (псевдо)объекте. Проследите по этой схеме действие каждого выполняемого оператора в конструкторе.

Упражнения Напишите следующие программы обработки матриц. (Задачи перечислены в порядке

возрастания сложности): 1. транспонирование прямоугольной матрицы (M×N); 2. умножение квадратной матрицы размерности N на вектор слева; 3. умножение прямоугольной матрицы (M×N) на вектор справа; 4. умножение прямоугольной матрицы (M×N) на вектор слева; 5. умножение прямоугольных матриц (M×N); 6. для прямоугольной (M×N) матрицы вычислить произведение исходной матрицы на

транспонированную; 7. для квадратных матриц A и B размерности N вычислить AB-BA; 8. из квадратной матрицы размерности N получить матрицу размера N-1 путем удаления i-й

строки и j-го столбца (i, j - параметры); 9. найти в матрице размерности N максимальный по модулю элемент и путем (только)

перестановки строк и столбцов поместить его в верхний левый угол; 10. найти в матрице размерности N минимальный по модулю элемент и путем (только)

перестановки строк и столбцов поместить его в нижний правый угол; 11. решение системы линейных уравнений размерности N методом Гаусса; 12. решение системы линейных уравнений размерности N методом Гаусса с выбором главного

элемента; 13. обращение матрицы размерности N методом Гаусса; 14. обращение матрицы размерности N методом Гаусса с выбором главного элемента; 15. вычисление детерминанта квадратной матрицы размерности N приведением к

треугольному виду; 16. (рекурсивное) вычисление детерминанта квадратной матрицы размерности N в

соответствии с определением детерминанта (эту задачу МОЖНО решить без использования malloc(), alloca() и тому подобных функций).