Книга: Теоретический минимум по Computer Science. Все что нужно программисту и разработчику
Назад: Глава 7. Компьютеры
Дальше: Заключение

Глава 8. Программирование

Когда кто-то скажет: «Мне нужен язык программирования, в котором достаточно только сказать, что мне нужно сделать», — дайте ему леденец на палочке.

Алан Перлис

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

435525.png определять лингвистику, которая управляет программным кодом;

435535.png хранить вашу драгоценную информацию внутри переменных;

435545.png обдумывать решения в условиях разных парадигм.

Мы не будем вдаваться в синтаксический и грамматический формализм. Расслабьтесь и продолжайте читать!

8.1. Лингвистика

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

Значения

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

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

Выражения

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

3

Бум! Мы буквально только что создали значение 3, написав: «3». Довольно прямолинейно. Как литералы можно создавать и другие типы значений. Большинство языков программирования позволит вам создать строковое значение Привет мир, набрав на клавиатуре «Привет мир». Функции же генерируют значение согласно методу или процедуре, которые запрограммированы в каком-то другом месте. Например:

getPacificTime()

Это выражение создало значение, равное текущему времени в Лос-Анджелесе. Если сейчас 4 часа утра, то метод вернет 4.

Еще одним базовым элементом любого языка программирования является оператор. Оператор может объединять простые выражения для формирования более сложных. Например, оператор + позволяет создать значение, равное времени в Нью-Йорке:

426486.png 

Когда в Лос-Анджелесе 4 часа утра, наше выражение сведется к 7. В действительности выражение — это любая запись, которую компьютер сможет свести к единственному значению. Большие выражения могут сочетаться с другими выражениями посредством операторов, формируя еще более крупные выражения. В конечном счете даже самое сложное выражение всегда будет вычислено и сведено к единственному значению.

Наряду с литералами, операторами и функциями выражения могут также содержать круглые скобки. Они позволяют управлять порядком выполнения операторов: (2 + 4)2 сводится к 62, которое, в свою очередь, сводится к 36. Выражение 2 + 42 сводится к 2 + 16 , а затем к 18.

Инструкции

В то время как выражение представляет значение, инструкция используется, чтобы дать компьютеру команду сделать что-то. Например, эта инструкция заставит его показать сообщение: print("привет мир").

432345.png 

Рис. 8.1.

Более сложные примеры включают условная инструкция if, инструкции циклов while и for. Разные языки программирования поддерживают разные типы инструкций.

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

8.2. Переменные

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

pi ← 3.142

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

var pi

pi = 3.142

Эта инструкция резервирует блок памяти, записывает в него значение 3,142 и привязывает имя "pi" к адресу блока памяти.

Типизация переменных

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

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

float pi;

pi = 3.142;

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

Некоторые языки предпочитают проверять типы динамически. Благодаря такой проверке любая переменная может хранить любой тип значения, и потому объявление типа не требуется. Однако во время выполнения кода производится дополнительная проверка типов переменных, чтобы гарантировать, что все операции между ними имеют смысл.

Область видимости переменных

Если бы все привязки имен были доступны и допустимы во всех точках в коде, то программирование считалось бы чрезвычайно трудным процессом. По мере того как программы становятся больше, одинаковые имена переменных (такие как time, length либо speed) все чаще начинают использоваться в разных частях программного кода.

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

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

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

Коллекция всех имен, доступных глобально, составляет пространство имен. Вы должны внимательно следить за пространством имен своих программ. Оно должно быть как можно меньше. В больших пространствах выше вероятность появления конфликтов имен.

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

8.3. Парадигмы

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

В программировании, как и в физике, подходы к решению задач полностью меняются в зависимости от парадигмы. Парадигма программирования — это определенная точка зрения на стиль программирования и методику.

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

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

Императивное программирование

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

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

432353.png 

Рис. 8.2. Типичная задача

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

Программирование машинного кода. Первым программистам приходилось вводить код в компьютер вручную, используя единицы и нули, но они тоже были ленивы. Они решили, что будет намного лучше записывать последовательность команд ЦП при помощи мнемоник, таких как CP для команды «копировать», MOV для команды «переместить», CMP для команды «сравнить» и т.п. Затем они написали программу, преобразующую мнемонический код в соответствующие ему двоичные числа процессорного кода. Так родился язык ассемблера (или ASM).

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

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

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

Структурное программирование. Когда-то давно программисты использовали команду GOTO для управления потоком выполнения. Она заставляет процесс перепрыгивать в другую часть кода. По мере того как программы усложнялись, стало почти невозможно понимать, что она делает. Различные потоки выполнения переплетались с командами GOTO и JUMP, создавая то, что называется запутанным кодом или спагетти-кодом. В 1968 году Дейкстра написал свой знаменитый манифест «О вреде оператора GOTO», и это вызвало революцию. Программный код стали разделять на логические части. Вместо ситуативных GOTO программисты начали использовать управляющие структуры (if, else, while, for). Это позволило намного упростить написание и отладку программ.

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

Декларативное программирование

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

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

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

Например, большинство языков функционального программирования поставляются вместе с универсальной функцией sort. Она может сортировать любую последовательность элементов. Функция sort на входе принимает другую функцию, которая определяет, как элементы будут сравниваться в процессе сортировки. Например, переменная coordinates содержит список географических точек. При наличии двух точек функция closer_to_home сообщает, какая из них находится ближе к вашему дому. Вы можете отсортировать список точек по критерию близости к вашему дому, как это сделано тут:

sort(coordinates, closer_to_home)

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

odd_numbers ← filter(numbers, number_is_odd)

number_is_odd — это функция, которая получает число и возвращает True, если число является нечетным, и False в противном случае.

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

squared_numbers ← map(numbers, square)

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

squared_numbers = [x**2 for x in numbers]

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

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

function reduce(list, initial_val, func)

    accumulator ← initial_val

    for item in list

        accumulator ← func(accumulator, item)

    return accumulator

Например, с помощью reduce можно просуммировать элементы в списке:

sum ← function(a, b): a + b

summed_numbers ← reduce(numbers, 0, sum)

Использование функции reduce упростит ваш программный код и сделает его более читаемым. Еще пример: если sentences — это просто список предложений, и вы хотите подсчитать общее количество слов в них, это можно реализовать так:

wsum ← function(a, b): a + length(split(b))

number_of_words ← reduce(sentences, 0, wsum)

Функция split разбивает строку на список слов, а функция length подсчитывает количество элементов в списке.

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

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

sum ← function(a, b): a + b

Функция sum ожидает два параметра, но ее можно вызвать с одним аргументом. Выражение sum(3) вернет не число, а новую каррированную функцию. При обращении к ней она вызовет sum и передаст ей 3 в первом аргументе. Ссылка на значение 3 замыкается в каррированной функции. Например:

sum_three ← sum(3)

print sum_three(1) # печатает "4".

 

special_sum ← sum(get_number())

print special_sum(1) # печатает "get_number() + 1".

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

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

function power_generator(base)

    function power(x)

        return power(x, base)

    return power

Мы можем использовать power_generator для генерации разных функций, которые вычисляют степень:

square ← power_generator(2)

print square(2)    # печатает 4.

 

cube ← power_generator(3)

print cube(2)      # печатает 8.

Обратите внимание, что возвращаемые функции square и cube сохраняют значение переменной base. Она существовала только в среде power_generator, но несмотря на это возвращаемые функции абсолютно независимы от power_generator. Еще раз: замыкание — это функция, которая имеет доступ к некоторым переменным за пределами собственного контекста.

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

GLOBAL_COUNT ← 0

function add(x)

    GLOBAL_COUNT ← GLOBAL_COUNT + x

    return GLOBAL_COUNT

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

function make_adder()

    n ← 0

    function adder(x)

        n ← x + n

        return n

    return adder

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

my_adder ← make_adder()

print my_adder(5)     # печатает 5.

print my_adder(2)     # печатает 7 (5 + 2).

print my_adder(3)     # печатает 10 (5 + 2 + 3).

Сопоставление с шаблоном. Функциональное программирование позволяет рассматривать функции как математические. При помощи математики мы можем описывать поведение функций в зависимости от входных данных. Обратите внимание на входной шаблон функции факториала:

0! = 1

n! = (n – 1)!

Функциональное программирование допускает сопоставление с шаблоном — то есть процесс распознавания этого шаблона. Вы можете просто написать:

factorial(0): 1

factorial(n): n × factorial(n — 1)

А вот императивное программирование требует, чтобы вы написали:

function factorial(n)

    if n = 0

        return 1

    else

        return n × factorial(n - 1)

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

Логическое программирование

Всегда, когда вашей задачей является решение ряда логических формул, вы можете воспользоваться логическим программированием. Разработчик перечисляет логические высказывания о ситуации, например такие, как в разделе «Логика» главы 1. Затем выполняются запросы, чтобы получить ответы из предоставленной модели. Компьютер отвечает за интерпретацию логических переменных и запросов. Он также создает пространство решений из высказываний и занимается поиском ответов на запросы, которые удовлетворяют всем этим высказываниям.

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

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

Подведем итоги

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

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

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

Хотелось бы надеяться, что у вас хватит смелости заняться каким-либо новым языком программирования. У них у всех есть что предложить вам. Так что закрывайте книгу — и начинайте программировать! 435557.png 

Полезные материалы

• Основы языков программирования (Essentials of Program­ming Langua­ges, Friedman, см. ).

• Макконнелл С. Совершенный код. Мастер-класс.

Любезно предоставлено .

Иногда такие сущности могут быть импортированы из заранее созданных внешних библиотек.

Источник: .

Если вы хотите обругать чей-то исходный код, скажите, что это спагетти 444733.png.

Назад: Глава 7. Компьютеры
Дальше: Заключение