Программирование на языке Python для сбора и анализа данных

Текст лекции: Щуров И.В., НИУ ВШЭ

Данный notebook является конспектом лекции по курсу «Программирование на языке Python для сбора и анализа данных» (НИУ ВШЭ, 2015-16). Он распространяется на условиях лицензии Creative Commons Attribution-Share Alike 4.0. При использовании обязательно упоминание автора курса и аффилиации. При наличии технической возможности необходимо также указать активную гиперссылку на страницу курса. Фрагменты кода, включенные в этот notebook, публикуются как общественное достояние.

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

Лекция №7. Особенности работы с изменяемыми типами данных. Множества. Ещё немного про строки. Файловый ввод-вывод

Особенности работы с изменяемыми типами данных

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

In [1]:
def my_func(x):
    x = x + 1
    return x

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

In [2]:
y = 5
print("Function returns", my_func(y))
print("y =", y)
Function returns 6
y = 5

Ничего неожиданного. Функция не изменила переменную y и не должна была этого делать. Давайте теперь попробуем сделать что-то аналогичное со списком.

In [3]:
def other_func(my_list):
    my_list.append(1)
    return my_list

Эта функция получает в качестве аргумента список, добавляет к нему в конец элемент 1 и возвращает то, что получилось. Попробуем её вызвать:

In [4]:
this_list = [6, 9, 33]
print("this_list before function:",this_list)
print("function returns:",other_func(this_list))
print("this_list after function", this_list)
this_list before function: [6, 9, 33]
function returns: [6, 9, 33, 1]
this_list after function [6, 9, 33, 1]

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

Почему это произошло? Чтобы разобрать в этом вопросе, проще всего посмотреть на визуализацию.

In [5]:
%load_ext tutormagic
In [6]:
%%tutor --lang python3
def other_func(my_list):
    my_list.append(1)
    return my_list

this_list = [6, 9, 33]
other_func(this_list)

Напомним, что в Python есть мир объектов (в котором живут списки, словари, кортежи и т.д.) и мир имён: имена указывают на объекты, причём у одного и того же объекта может быть несколько имён (подробности см. в лекции №2).

Теперь вернёмся к нашей визуализации. На первом шаге создаётся функция other_func, а на втором — список this_list. Самое странное происходит на следующем шаге: после того, как мы попадаем в функцию other_func (Step 4), переменная my_list (находящаяся во фрейме other_func, то есть это локальна переменная, созданная внутри функции) указывает ровно на тот же самый список, что и глобальная переменная this_list (которая сама по себе изнутри функции не видна).

Это важный момент. Локальными могут быть переменные (имена), у них может быть ограниченная область видимости. Но все объекты (списки, словари, кортежи, что угодно) могут быть доступны из любой функции, если ей была передана ссылка на этот объект. Более того, когда мы передаём функции какой-нибудь объект, на самом деле передаётся именно ссылка на него: сам объект не копируется. Это позволяет сделать код эффективным: если объект большой и занимает много места в памяти, его копирование требовало бы много времени — если бы мы копировали весь объект целиком при каждом вызове функции, вызовы функций стали бы очень «дорогими» в смысле затрат времени (они и так недешёвые в Python, но по другим причинам). Если вы когда-нибудь изучали C или Pascal, вам может быть полезно знать: в Python не бывает передачи аргумента функции «по значению», аргументы всегда передаются «по ссылке».

Далее, если объект является изменяемым (а список именно таков), то функция, получив ссылку на него, может его спокойно изменить, используя соответствующие методы.

Почему же тогда аналогичная ситуация не происходит в примере с my_func() выше? Дело в том, что в том случае функции передано число. Число является неизменяемым объектом (как, кстати, и строки и некоторые другие более сложные типы данных). Невозможно подействовать на число 2 волшебной палочкой и превратить его в число 3 (или в любое другое), такой операции нет. А волшебная палочка, добавляющая в список новый элемент, есть — это метод append(). Единственная возможность увеличить значение переменной x, в которой лежало число 2, на единицу — это создать новое число (тройку), после чего сказать, что переменая x теперь будет указывать на это новое число. Именно этому соответствует строка x = x + 1: оператор присваивания (=) как раз и говорит, что теперь переменная x будет именем для нового объекта (который получается путём вычисления значения выражения x + 1).

Две сортировки

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

In [7]:
%%tutor --lang python3
def return_sorted(my_list):
    my_list = sorted(my_list)
    return my_list

this_list = [33, 1, 55]
print("this_list before function:", this_list)
print("function returned:", return_sorted(this_list))
print("this_list after function:", this_list)
In [8]:
%%tutor --lang python3
def sort_and_return(my_list):
    my_list.sort()
    return my_list

this_list = [33, 1, 55]
print("this_list before function:", this_list)
print("function returned:", sort_and_return(this_list))
print("this_list after function:", this_list)

Итак, в чём разница?

Функции return_sorted() и sort_and_return() выполняют примерно одну и ту же работу: принимают на вход список, сортируют его и возвращают. В тот момент, когда происходит вход в функцию (Step 5), ситуация в обоих фрагментах идентична: вне функции есть переменная this_list, внутри функции есть переменная my_list, они обе указывают на один и тот же список. Ключевое различие происходит на следующем шаге. Функция return_sorted() использует функцию sorted(), создающую новый список. Затем она присваивает (=) результат выполнения sorted() переменной my_list. Это приводит к тому, что переменная с этим именем начинает ссылаться на новый объект (Step 7), а старый (на который по-прежнему ссылается this_list) остаётся в неприкосновенности. Совсем иначе действует sort_and_return() — она использует метод sort(), сортирующий список in place, внутри самого себя. При этом новый объект не создаётся, операция присваивания не используется и переменная my_list продолжает ссылаться на тот же список, что и раньше. Просто он оказывается отсортированным.

Обратите внимание на схожесть return_sorted() и my_func() из примера выше: в обоих случаях используется оператор присваивания. Разница состоит в том, что с числовой переменной никак иначе поступить было нельзя, поскольку числа неизменяемы, а в случае со списком у разработчика есть выбор: можно создать новый объект и присвоить его старой переменной, а можно изменить уже существующий объект, не создавая новый.

Создание копии

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

In [9]:
def return_append(L, a):
    new_L = L.copy()
    new_L.append(a)
    return new_L
In [10]:
outer_list = [7, 8, 9]
print("outer_list before funciton", outer_list)
print("function returned", return_append(outer_list, 55))
print("outer_list after function", outer_list)
outer_list before funciton [7, 8, 9]
function returned [7, 8, 9, 55]
outer_list after function [7, 8, 9]

Здесь главная хитрость в использовании L.copy() — напомним, что этот метод создаёт копию существующего списка. Дальше мы снова выполняем операцию присваивания (то есть теперь new_L является именем для копии списка L, а не для самого списка L) и мы можем делать с этим новым списком new_L что угодно. Старый список не изменится.

Не только вызовы функций

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

In [11]:
some_list = [7, 9, 11]
for x in some_list:
    x = x + 1
print(some_list)
[7, 9, 11]

Список some_list не поменялся, и в этом нет ничего удивительного. Но давайте теперь рассмотрим чуть более сложную ситуацию со списком списков.

In [12]:
table = [[1, 5], [7, 9]]
for row in table:
    row.append(77)
print(table)
[[1, 5, 77], [7, 9, 77]]

И снова «ой». Что случилось? Посмотрим на визуализаторе.

In [13]:
%%tutor --lang python3
table = [[1, 5], [7, 9]]
for row in table:
    row.append(77)
print(table)

При выполнении первого шага цикла (Step 3) в переменную row записывается первый элемент списока table. Однако, этот элемент сам является списком — а точнее ссылкой на список. На следующем шаге (Step 4) к этому списку добавляется элемент 77. Потом row становится ссылкой на второй элемент списка table. К ней тоже добавляется элемент 77.

Обратите внимание на параллель с предыдущим сюжетом: здесь тоже участвует вызов метода списка, который меняет этот список in place.

Головоломка

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

A = [[]]*5
A[0].append(1)
print(A)

Изменение итерируемого объекта в цикле

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

In [14]:
L = [6, 9, 44, 8]
print(L.pop())
print(L)
8
[6, 9, 44]

Он удаляет последний элемент из списка и возвращает его же. Применим теперь его следующим образом:

In [15]:
L = [7, 8, 9, 10]
for x in L:
    print("Pop element", L.pop())
    print(x)
Pop element 10
7
Pop element 9
8

Цикл исполняется два раза: к тому моменту, как цикл закончит обрабатывать элемент 8, элементы 9 и 10 из списка будут уже удалены, необработанных элементов в списке не останется и цикл прекратится.

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

Со словарями ситуация иная.

In [16]:
d = {1:2, 3:4}
for k, v in d.items():
    del d[3]
    print(k, v)
1 2
---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
<ipython-input-16-41bfd3656279> in <module>()
      1 d = {1:2, 3:4}
----> 2 for k, v in d.items():
      3     del d[3]
      4     print(k, v)

RuntimeError: dictionary changed size during iteration

Здесь команда del d[3] удаляет элемент с ключом 3 из словаря. Поскольку порядок итерирования элементов словаря не определён, как корректно продолжить итерации после того, как размер словаря был изменён, никто не знает. Поэтому такая операция запрещена.

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

In [17]:
d={1:2, 3:4}
for k, v in d.items():
    v = v + 1
print(d)
{1: 2, 3: 4}

На самом деле, эту задачу следует решать так:

In [18]:
d = {1:2, 3:4}
for k in d:
    d[k] = d[k] + 1
print(d)
{1: 3, 3: 5}

Множества

Ещё один базовый тип данных в Python — это множество. Оно соответствует математическому понятию множества — то есть набора каких-то элементов. Каждый элемент может или входить в множество, или не входить.

Если вы в данный момент являетесь слушателем нашего курса по Python, то вы принадлежите множеству слушателей. Нельзя быть «дважды слушателем курса»: каждый элемент может входить в множество только один раз.

In [19]:
my_set = {6, 9, 11, 11, 9, 'hello'}
In [20]:
my_set
Out[20]:
{11, 9, 'hello', 6}

Как видно из этого простого примера, элементы множества также не упорядочены.

In [21]:
{6, 9, 11, 11, 9, 'hello'} == {9, 'hello', 11, 6}
Out[21]:
True

Вот так можно проверить, лежит ли элемент в множестве.

In [22]:
9 in my_set
Out[22]:
True
In [23]:
10 in my_set
Out[23]:
False

Конечно, оператор in работает не только для множеств. Например, 4 in [2, 4, 8, 10] вернёт True. Однако для списков эта операция является медленной — вернее, массовой: чем больше список, тем больше операций сравнения нужно произвести, чтобы понять, лежит ли в нём какой-то конкретный элемент. В случае с множествами время на проверки практически не растёт с ростом числа элементов множества.

С множествами можно делать разные операции — с ними мы знакомы по математическим курсам. Например, объединение и пересечение двух множеств даёт новое множество:

In [24]:
{6, 8, 9} | {6, 11, 7}
Out[24]:
{6, 7, 8, 9, 11}
In [25]:
{6, 8, 9} & {6, 11, 7}
Out[25]:
{6}

Отметим ещё раз: порядок элементов во множестве не определён. Если вам нужно вывести элементы множества в каком-то заранее заданном порядке, то можно превратить его в сортированный список с помощью функции sorted().

In [26]:
s = {"Hello", "World", "Test", "Guest", "Aaaaa", "Zzzzz","Zz","Q"}
print(s)
print(sorted(s)) # здесь выводится уже не множество, а список: обратите внимание на квадратные скобки
{'World', 'Guest', 'Hello', 'Q', 'Zzzzz', 'Aaaaa', 'Test', 'Zz'}
['Aaaaa', 'Guest', 'Hello', 'Q', 'Test', 'World', 'Zz', 'Zzzzz']

Пример использования множеств

допустим, мы просим пользователя ввести команду, но хотим дать ему возможность ввести одну и ту же команду разными способами. Например, чтобы остановить программу, пользователь может ввести слово stop или STOP или Stop или просто букву s или S. Можно обработать этот случай с помощью нескольких условий, соединённых or:

In [27]:
s = 'stop'
if s == 'stop' or s == 'Stop' or s == 'STOP' or s == 'S' or s == 's':
    print("Okay, stopping")
Okay, stopping

А можно создать множество для всех возможных вариаций команды stop и проверять, входит ли наша команда в это множество:

In [28]:
s = 'stop'
STOPS = {'stop', 'Stop', 'STOP', 'S', 's'}
if s in STOPS:
    print("Okay, stopping")    
Okay, stopping

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

Ещё немного про строки

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

In [29]:
s = "hello world, hello"
new_s = s.replace("hello", "Hi")
print(new_s)
print(s)
Hi world, Hi
hello world, hello

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

Если вы хотели заменить только несколько первых вхождений (например, только первое слово hello, но не второе), можно было добавить третий аргумент метода replace — он показывает, сколько раз нужно произвести замену.

In [30]:
"hello world, hello".replace("hello", "Hi", 1)
Out[30]:
'Hi world, hello'

Вот так можно найти подстроку в строке:

In [31]:
s.index("world")
Out[31]:
6
In [32]:
s.find("world")
Out[32]:
6

Оба метода возвращают индекс первого символа подстроки. Разница состоит в том, что если index() не сможет найти подстроку вообще, он выдаст ошибку (exception), а если с аналогичной проблемой столкнётся find(), то он вернёт число -1 в качестве индекса.

Кстати, проверить, входит ли подстрока в строку, можно ещё вот так:

In [33]:
"world" in s
Out[33]:
True

А вот так можно посчитать, сколько подстрок в строке встречается:

In [34]:
s.count("o")
Out[34]:
3

Подробности можно найти в официциальной справке.

Файловый ввод-вывод

Мы начинаем работать с файлами. Сейчас будем обсуждать только чтение и запись. О том, как запускать файлы на исполнение, отдельная история — для этого существует метод subprocess, мы до него когда-нибудь дойдём. (Может быть.) Также для начала речь пойдёт о текстовых файлах или похожих на текстовые (например, код на Python или CSV-файл будет текстовым). Бывают также бинарные файлы, которые «глазками» читать бесполезно — о некоторых из них будет отдельный рассказ.

Допустим, мы хотим прочитать файл.

In [35]:
f = open("func.txt")
s = f.read()
f.close()
print(s)
blockdiag {
    args [label="Аргументы"]
    function [label="Функция"]
    sideffects [label="Сторонние эффекты"]
    output [label="Возвращаемое значение"]

    args -> function -> output;
    function -> sideffects;
}

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

In [36]:
import os
os.getcwd()
Out[36]:
'/Users/user/prj/oldhse-2010-11/repo/2015-16/all-python'

Функция open() вернула объект типа file — переменную, которую можно использовать, чтобы работать с файлом. Затем мы считали содержимое файла в строчку s, после чего закрыли файл. Закрывать файлы очень полезно: если вы забудете закрыть файл, другое приложение не сможет его открыть (например, чтобы в него что-нибудь записать).

Функция read() считает весь файл в одну большую строковую переменную. Это не всегда бывает удобно (учитывая, что строки в Python неизменяемые и из-за этого работа с ними не всегда бывает эффективной), поэтому есть разные другие сценарии работы с файлами. Например, можно считать содержимое файла в список, разбив по строкам.

In [37]:
f = open("func.txt")
lines = f.readlines()
f.close()
In [38]:
print(lines)
['blockdiag {\n', '    args [label="Аргументы"]\n', '    function [label="Функция"]\n', '    sideffects [label="Сторонние эффекты"]\n', '    output [label="Возвращаемое значение"]\n', '\n', '    args -> function -> output;\n', '    function -> sideffects;\n', '}\n']

Заметим, что каждая из строк заканивается символом перевода строки \n — они присутствовали в файле и мы их честно из него считали. Вот так можно вывести файл по строкам, пронумеровав их:

In [39]:
for i, line in enumerate(lines, 1):
    print(i, line, end="")
1 blockdiag {
2     args [label="Аргументы"]
3     function [label="Функция"]
4     sideffects [label="Сторонние эффекты"]
5     output [label="Возвращаемое значение"]
6 
7     args -> function -> output;
8     function -> sideffects;
9 }

Другой способ это сделать — не создавать отдельный список, а итерировать прямо сразу файловый объект.

In [40]:
f = open("func.txt")
for i, line in enumerate(f, 1):
    print(i, line, end="")
f.close()
1 blockdiag {
2     args [label="Аргументы"]
3     function [label="Функция"]
4     sideffects [label="Сторонние эффекты"]
5     output [label="Возвращаемое значение"]
6 
7     args -> function -> output;
8     function -> sideffects;
9 }

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

Здесь есть, правда, некоторые хитрости. Рассмотрим, например, такой код:

In [41]:
f = open("func.txt")
for line in f:
    print(line, end="")
print("----The next one----")
for line in f:
    print(line, end="")
f.close()
blockdiag {
    args [label="Аргументы"]
    function [label="Функция"]
    sideffects [label="Сторонние эффекты"]
    output [label="Возвращаемое значение"]

    args -> function -> output;
    function -> sideffects;
}
----The next one----

Что здесь произошло? Почему второй цикл вообще не выполнился (ничего не выведено после строчки ----The next one----)? Очень просто: переменная f, хоть и прикидывается списком строк, когда мы её итерируем, на самом деле таковым не является. В действительности при открытии файла мы запоминаем позицию, на которой мы этот файл читаем. Изначально она указывает на самое начало файла, но с каждой итерацией сдвигается. Когда мы прочитаем файл целиком, дальнейшие попытки из него что-то прочитать ни к чему не приведут: указатель текущей позиции сдвинулся до самого конца и файл закончился.

Впрочем, есть возможность вернуться в начало: для этого нужно использовать метод seek().

In [42]:
f = open("func.txt")
for line in f:
    print(line, end="")
print("----The next one----")
f.seek(0)
for line in f:
    print(line, end="")
f.close()
blockdiag {
    args [label="Аргументы"]
    function [label="Функция"]
    sideffects [label="Сторонние эффекты"]
    output [label="Возвращаемое значение"]

    args -> function -> output;
    function -> sideffects;
}
----The next one----
blockdiag {
    args [label="Аргументы"]
    function [label="Функция"]
    sideffects [label="Сторонние эффекты"]
    output [label="Возвращаемое значение"]

    args -> function -> output;
    function -> sideffects;
}

Запись в файлы

Чтобы содать файл и записать в него что-то, нужно открыть его на запись. Это делается путём передачи функции open второго аргумента — здесь надо написать строчку "w" (от write).

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

Записывать информацию в файл, открытый на запись, можно, например, с помощью метода write().

In [43]:
f = open("other.txt", "w")
f.write("Hello\n")
f.close()

Проверим, что получилось:

In [44]:
open('other.txt').read()
/usr/local/lib/python3.5/site-packages/ipykernel/__main__.py:1: ResourceWarning: unclosed file <_io.TextIOWrapper name='other.txt' mode='r' encoding='UTF-8'>
  if __name__ == '__main__':
Out[44]:
'Hello\n'

Мы видим, что действительно записали в файл other.txt строчку Hello\n. Заметим, что здесь мы открыли файл на запись, но не стали присваивать файловый объект какой-либо переменной, а сразу вызвали от него метод read(). В этом случае файл будет закрыт автоматически через некоторое время после выполнения этой команды. (Система выдаёт предупреждение о том, что мы не закрыли файл явно — в некоторых случаях это может приводить к каким-то проблемам.)

Есть и другие методы для работы с файлами, но на сегодня это всё.