Примеры

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

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

Генератор имен пользователей

Представьте, что мы выполняем настройку новой компьютерной системы и нам необходимо сгенерировать имена пользователей для всех служащих нашей компании. У нас имеется простой текстовый файл (в кодировке UTF-8), где каждая строка представляет собой запись из полей, разделенных двоеточиями. Каждая запись соответствует одному сотруднику компании и содержит следующие поля: уникальный идентификатор служащего, имя, отчество (это поле может быть пустым), фамилию и название отдела.

Ниже в качестве примера приводятся несколько строк из файла users.txt :

1080:Priscillia:Forbes:Shepard:Cleaning Services
4382:Devan::Fielder:Public Relations
6285:Grey::Collyer:Public Relations
6201:Kierah::Battaile:Catering

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

Terminal> ./generate_usernames.py users.txt
Name                               ID   Username 
-------------------------------- ------ ---------
Battaile, Kierah................ (6201) kbattail 
Blakey, Kelci-Louise............ (0641) kblakey  
Blenkinsop, Keirien............. (4541) kblenkin 
Clifford, Rebbekkah............. (6263) rcliffor 

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

ID, FORENAME, MIDDLENAME, SURNAME, DEPARTMENT = range(5)

В языке Python общепринято использовать только символы верхнего регистра для идентификаторов, которые будут играть роль констант.

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

User = collections.namedtuple("User",
            "username forename middlename surname id")

Основная логика программы сосредоточена в функции main():

def main():
    if len(sys.argv) == 1 or sys.argv[1] in {"-h", "--help"}:
        print("usage: {0} file1 [file2 [... fileN]]".format(
              sys.argv[0]))
        sys.exit()

    usernames = set()
    users = {}
    for filename in sys.argv[1:]:
        with open(filename, encoding="utf8") as file:
            for line in file:
                line = line.rstrip()
                if line:
                    user = process_line(line, usernames)
                    users[(user.surname.lower(), user.forename.lower(),
                            user.id)] = user
    print_users(users)

Если пользователь не ввел в командной строке имя какого-нибудь файла или ввел параметр -h или --help, то программа просто выводит текст сообщения с инструкцией о порядке использования и завершает работу.

Из каждой прочитанной строки удаляются любые завершающие пробельные символы (такие как \n), и обработка строки продолжается, только если она не пустая. Это означает, что если в данных содержатся пустые строки, они будут просто проигнорированы.

Все сгенерированные имена пользователей сохраняются в множестве usernames, чтобы гарантировать отсутствие повторяющихся имен пользователей. Сами данные сохраняются в словаре users. Информация о каждом пользователе сохраняется в виде элемента словаря, ключом которого является кортеж, содержащий фамилию сотрудника, его имя и идентификатор, а значением – именованный кортеж типа User. Использование кортежа, содержащего фамилию сотрудника, его имя и идентификатор, в качестве ключа обеспечивает возможность вызывать функцию sorted() для словаря и получать итерируемый объект, в котором элементы будут упорядочены в требуемом нам порядке (то есть фамилия, имя, идентификатор), избежав необходимости создавать функцию, которую пришлось бы передавать в качестве аргумента key.

def process_line(line, usernames):
    fields = line.split(":")
    username = generate_username(fields, usernames)
    user = User(username, fields[FORENAME], fields[MIDDLENAME],
                fields[SURNAME], fields[ID])
    return user

Поскольку все записи имеют очень простой формат, и мы уже удалили из строки завершающие пробельные символы, извлечь отдельные поля можно простой разбивкой строки по двоеточиям. Мы передаем список полей и множество usernames в функцию generate_username() и затем создаем экземпляр именованного кортежа User, который возвращается вызывающей программе (функции main()), которая в свою очередь вставляет информацию о пользователе в словарь users, готовый для вывода на экран.

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

user = User(username, fields[1], fields[2], fields[3], fields[0])

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

def generate_username(fields, usernames):
    username = ((fields[FORENAME][0] + fields[MIDDLENAME][:1] +
                 fields[SURNAME]).replace("-", "").replace("'", ""))
    username = original_name = username[:8].lower()
    count = 1
    while username in usernames:
        username = "{0}{1}".format(original_name, count)
        count += 1
    usernames.add(username)
    return username

При первой попытке имя пользователя создается путем конкатенации первого символа имени, первого символа отчества и фамилии целиком, после чего из полученной строки удаляются дефисы и апострофы. Выражение, извлекающее первый символ отчества, таит в себе одну хитрость. Если просто использовать обращение fields[MIDDLENAME][0], то в случае отсутствия отчества будет возбуждено исключение IndexError. Но при использовании операции извлечения среза мы получаем либо первый символ отчества, либо пустую строку.

Затем мы переводим все символы полученного имени пользователя в нижний регистр и ограничиваем его длину восемью символами. Если имя пользователя уже занято (то есть оно уже присутствует в множестве usernames), предпринимается попытка добавить в конец имени пользователя символ "1", если это имя пользователя тоже занято, тогда предпринимается попытка добавить символ "2" и т. д., пока не будет получено незанятое имя пользователя. После этого имя пользователя добавляется в множество usernames и возвращается вызывающей программе.

def print_users(users):
    namewidth = 32
    usernamewidth = 9

    print("{0:<{nw}} {1:^6} {2:{uw}}".format(
          "Name", "ID", "Username", nw=namewidth, uw=usernamewidth))
    print("{0:-<{nw}} {0:-<6} {0:-<{uw}}".format(
          "", nw=namewidth, uw=usernamewidth))

    for key in sorted(users):
        user = users[key]
        initial = ""
        if user.middlename:
            initial = " " + user.middlename[0]
        name = "{0.surname}, {0.forename}{1}".format(user, initial)
        print("{0:.<{nw}} ({1.id:4}) {1.username:{uw}}".format(
              name, user, nw=namewidth, uw=usernamewidth))

После обработки всех записей вызывается функция print_users(), которой в качестве параметра передается словарь users.

Первая инструкция print() выводит заголовки столбцов. Вторая инструкция print() выводит дефисы под каждым из заголовков. В этой второй инструкции метод str.format() используется довольно оригинальным образом. Для вывода ему определяется строка "", то есть пустая строка; в результате при выводе пустой строки мы получаем строку из дефисов заданной ширины поля вывода.

Затем мы используем цикл for ... in для вывода информации о каждом пользователе, извлекая ключи из отсортированного словаря. Для удобства мы создаем переменную user, чтобы не вводить каждый раз users[key] в оставшейся части функции. В цикле сначала вызывается метод str.format(), чтобы записать в переменную name фамилию сотрудника, имя и необязательный первый символ отчества. Обращение к элементам в именованном кортеже user производится по их именам. Собрав строку с именем пользователя, мы выводим информацию о пользователе, ограничивая ширину каждого столбца (имя сотрудника, идентификатор и имя пользователя) желаемыми значениями.

Полный программный код программы находится в файле generate_usernames.py

Статистика

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

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

Terminal> python statistics.py statistics.dat 
count     =    129
mean      =    170.49
median    =     51.00
mode      =     50.00
std. dev. =    269.50

Здесь видно, что прочитано \( 129 \) чисел, из которых наиболее часто встречается число \( 50 \) со стандартным отклонением по выборке \( 269.50 \).

Сами статистические ххарактеристики хранятся в именованном кортеже Statistics:

Statistics = collections.namedtuple("Statistics",
                                    "mean mode median std_dev")

Функция main() может служить схематическим отображением структуры программы:

def main():
    if len(sys.argv) == 1 or sys.argv[1] in {"-h", "--help"}:
        print("usage: {0} file1 [file2 [... fileN]]".format(
              sys.argv[0]))
        sys.exit()

    numbers = []
    frequencies = collections.defaultdict(int)
    for filename in sys.argv[1:]:
        read_data(filename, numbers, frequencies)
    if numbers:
        statistics = calculate_statistics(numbers, frequencies)
        print_results(len(numbers), statistics)
    else:
        print("no numbers found")

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

Затем выполняется обход списка файлов и производится чтение данных из них. В качестве дополнительных аргументов мы передаем функции read_data() список и словарь со значениями по умолчанию, чтобы она могла обновлять их. После чтения всех данных мы исходим из предположения, что некоторые числа были благополучно прочитаны, и вызываем функцию calculate_statistics(). Она возвращает именованный кортеж типа Statistics, который затем используется для вывода результатов.

def read_data(filename, numbers, frequencies):
    with open(filename, encoding="ascii") as file:
        for lino, line in enumerate(file, start=1):
            for x in line.split():
                try:
                    number = float(x)
                    numbers.append(number)
                    frequencies[number] += 1
                except ValueError as err:
                    print("{filename}:{lino}: skipping {x}: {err}".format(
                        **locals()))

Каждая строка разбивается по пробельным символам, после чего производится попытка преобразовать каждый элемент в число типа float. Если преобразование удалось, следовательно, это либо целое число, либо число с плавающей точкой в десятичной или в экспоненциальной форме. Полученное число добавляется в список numbers и выполняется обновление словаря frequencies со значениями по умолчанию. (Если бы здесь использовался обычный словарь dict, программный код, выполняющий обновление словаря, мог бы выглядеть так: frequencies[number] = frequencies.get(number, 0) + 1.) Если преобразование потерпело неудачу, выводится номер строки (счет строк в текстовых файлах по традиции начинается с 1), текст, который программа пыталась преобразовать в число, и сообщение об ошибке, соответствующее исключению ValueError.

def calculate_statistics(numbers, frequencies):
    mean = sum(numbers) / len(numbers)
    mode = calculate_mode(frequencies, 3)
    median = calculate_median(numbers)
    std_dev = calculate_std_dev(numbers, mean)
    return Statistics(mean, mode, median, std_dev)

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

def calculate_mode(frequencies, maximum_modes):
    highest_frequency = max(frequencies.values())
    mode = [number for number, frequency in frequencies.items()
            if frequency == highest_frequency]
    if not (1 <= len(mode) <= maximum_modes):
        mode = None
    else:
        mode.sort()
    return mode

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

Функция max() используется для поиска наибольшего значения в словаре frequencies. Затем с помощью генератора списков создается список из значений, которые равны наивысшему значению. Поскольку числа могут быть с плавающей точкой, мы сравниваем абсолютное значение разницы (используя функцию math.fabs(), поскольку она лучше подходит для случаев сравнения малых величин, близких к порогу точности представления числовых значений в компьютере, чем abs()) с наименьшим значением, которое может быть представлено компьютером.

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

def calculate_median(numbers):
    numbers = sorted(numbers)
    middle = len(numbers) // 2
    median = numbers[middle]
    if len(numbers) % 2 == 0:
        median = (median + numbers[middle - 1]) / 2
    return median

Медиана («среднее значение») – это значение, находящееся в середине упорядоченной выборки чисел, за исключением случая, когда в выборке присутствует четное число чисел, – тогда значение медианы определяется как среднее арифметическое значение двух чисел, находящихся в середине.

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

def calculate_std_dev(numbers, mean):
    total = 0
    for number in numbers:
        total += ((number - mean) ** 2)
    variance = total / (len(numbers) - 1)
    return math.sqrt(variance)

Стандартное отклонение – это мера дисперсии; оно определяет, как сильно отклоняются значения в выборке от среднего значения. Вычисление стандартного отклонения в этой функции выполняется по формуле $$ \frac{\sqrt{\sum(x + \bar{x})^2}}{n-1}, $$ где \( x \) — очередное число, \( \bar{x} \) — среднее значение, а \( n \) — количество чисел.

def print_results(count, statistics):
    real = "9.2f"

    if statistics.mode is None:
        modeline = ""
    elif len(statistics.mode) == 1:
        modeline = "mode      = {0:{fmt}}\n".format(statistics.mode[0], fmt=real)
    else:
        modeline = ("mode      = [" + ", ".join(["{0:.2f}".format(m)
                    for m in statistics.mode]) + "]\n")

    print("""\ 
count     = {0:6}
mean      = {mean:{fmt}}
median    = {median:{fmt}}
{1}\ 
std. dev. = {std_dev:{fmt}}""".format(count, modeline, fmt=real, **statistics._asdict()))

Большая часть этой функции связана с форматированием списка модальных значений в строку modeline. Если модальные значения отсутствуют, то строка modeline вообще не выводится. Если модальное значение единственное, список модальных значений содержит единственный элемент (mode[0]), который и выводится с той же строкой форматирования, что используется при выводе других статистических значений. Если имеется несколько модальных значений, они выводятся как список, в котором каждое значение форматируется отдельно. Делается это с помощью генератора списков, который позволяет получить список строк с модальными значениями; строки затем объединяются в единую строку, где отделяются друг от друга запятой с пробелом (", "). Последняя инструкция print() в самом конце получилась очень простой благодаря использованию именованного кортежа. Он позволяет обращаться к статистическим значениям в объекте statistics, используя не числовые индексы, а их имена, а благодаря строкам в тройных кавычках мы смогли отформатировать выводимый текст наглядным способом.

В этой функции имеется одна особенность, о которой следует упомянуть отдельно. Строка с модальными значениями выводится с помощью элемента строки формата {2}, за которым следует символ обратного слеша. Символ обратного слеша экранирует символ перевода строки, поэтому если строка с модальными значениями пустая, то пустая строка выводиться не будет. Именно по этой причине мы вынуждены были добавить символ \n в конец строки modeline, если она не пустая.