В работе мне нередко случается реализовывать долгие процессы, где не обойтись без прогресс-индикатора. Проблемы начались, когда процессы стали слишком сложными, но при этом хотелось иметь один непрерывный прогресс-индикатор для всего процесса. К примеру, процесс может состоять из вызовов функций Asub, Bsub и Csub, каждая из которых выполняется довольно долго (скажем, примерно 10%, 20% и 70% общего времени). Пусть Asub содержит два цикла, идущих подряд, Bsub несколько вложенных циклов, а Csub один цикл, но при этом в середине этого цикла вызывает Asub. Решая задачу в лоб, можно довести код до такого состояния, что треть всех строчек будет вычислять текущий процент и определять, пора ли его обновлять в UI, а функция Asub принимать дополнительные параметры, чтобы определить, какой диапазон процентов ей отображать (от 0 до 10, если вызвана из основного процесса или какой-то другой, если вызвана изнутри Csub). В результате код теряет читаемость, а поддерживать его становится сложнее. И нас ждут приятные минуты, когда мы захотим повторно использовать Bsub в другом месте, но уже не в середине, а в конце общего процесса, так что выводимые ей проценты от 10% до 30% будут не к месту. Я пришёл к выводу, что с этим надо что-то делать.
Я поставил следующие требования. Добавление в существующий код отображения прогресса не должно:
Простой пример может выглядеть так:
Теперь посмотрим, что можно сделать с циклами. Предположим, что все итерации цикла выполняются примерно одинаковое время и количество итераций известно. Это не сработает с циклами вроде
Первую версию модуля
Замечания и пожелания приветствуются :-)
Я поставил следующие требования. Добавление в существующий код отображения прогресса не должно:
- Менять прототипы существующих функций и методов;
- Добавлять новых переменных внутри функций;
- Содержать сколь-нибудь нетривиальные вычисления текущего прогресса (скажем,
100*$i/$n
уже считается нетривиальным); - Мучать таймер или отсчитывать итерации с целью понять, надо ли обновить прогресс-индикатор или не тратить время на эту дорогостоящую операцию.
Разделение процесса на подпроцессы
Простой пример может выглядеть так:
init_progress;
# делаем половину процесса
do_first_half;
update_progress 50;
# делаем вторую половину
do_last_half;
update_progress 100;
Теперь предположим, что каждая половина — это вызов долгой функции, которая может выдавать свою информацию о прогрессе. При этом она не знает, в каком контексте она была вызвана и какой диапазон общего прогресс-индикатора отведён на её выполнение. Естественная реализация была бы примерно такой:sub do_first_half() {
# делаем кусок
update_progress 33;
# делаем кусок
update_progress 66;
# делаем кусок
update_progress 100;
}
То есть мы сообщаем информацию о своём прогрессе, а уже кто-нибудь пусть отобразит её на нужный диапазон (в нашем случае 0-50%). Здесь мне пришла в голову аналогия со стеком матриц OpenGL, где любые аффинные преобразования трёхмерных координат описываются матрицей 4×4 и последовательность преобразований помещается в стек, а когда дело доходит до задания вершин конкретного объекта, мы указываем конкретные числа без всяких вычислений. OpenGL сам преобразует координаты, умножая на конкретную матрицу. Здесь у нас по сути тоже координаты на прогресс-индикаторе, только одномерные. Аффинное преобразование описывается двумя числами: перенос и масштабирование. Мы будем складывать преобразования в стек, а функция update_progress
выполнит необходимое преобразование и передаст рендереру уже преобразованные координаты:# Стек содержит пары [масштабирование, перенос]
my @stack = ([1, 0]);
sub update_progress($) {
my $percent = shift;
$percent = $stack[-1][ 0 ]*$percent + $stack[-1][1];
renderer($percent);
}
Теперь добавим функции push_progress
и pop_progress
. Для удобства использования будем передавать в push_progress
не масштабирование и перенос, а диапазон, на который надо отобразить последующие проценты. Разумеется, если какое-то преобразование уже действует, то параметры push_progress
также надо преобразовать:sub push_progress($$) {
# Начало и конец диапазона
my ($s,$e) = @_;
# Преобразуем в соответствии с активным преобразованием
($s,$e) = map {$stack[-1][ 0 ]*$_ + $stack[-1][1]} ($s,$e);
# И помещаем в стек
push @stack, [($e-$s)/100, $s];
}
sub pop_progress() {
pop @stack;
}
Теперь осталось только обернуть вызовы функций do_first_half
и do_last_half
в скобки push_progress/pop_progress
:push_progress 0,50;
do_first_half;
pop_progress;
push_progress 50,100;
do_last_half;
pop_progress;
Уже неплохо. К сожалению, придётся следить, чтобы каждому push_progress
соответствовал парный pop_progress
. Однако мы можем фрагмент кода между push_progress
и pop_progress
обернуть в блок и передать в функцию sub_progress
примерно такого вида:sub sub_progress(&$$) {
my ($code, $s, $e) = @_;
push_progress $s, $e;
my @retval = &{$code}();
update_progress 100;
pop_progress;
return @retval;
}
Основной код тогда упростится:sub_progress {do_first_half} 0,50;
sub_progress {do_last_half} 50,100;
Заметьте, что перед pop_progress
я вызвал update_progress(100)
на всякий случай, если блок забыл это сделать. Теперь становится ясно, что параметр $s
не нужен: вместо него можно использовать последнее выведенное значение прогресс-индикатора.Циклы
Теперь посмотрим, что можно сделать с циклами. Предположим, что все итерации цикла выполняются примерно одинаковое время и количество итераций известно. Это не сработает с циклами вроде
for($i=1; $i<=1024; $i*=2)
, однако сработает с любыми циклами типа foreach
(кстати, приведённый цикл легко преобразуется в foreach
: for(map {2**$_} 0..10)
). Наш for_progress
будет выполнять такую цепочку действий для каждой итерации: поместить в стек диапазон [$i/$n*100, ($i+1)/$n*100]
, где $i — номер итерации, а $n — количество элементов списка, загрузить текущий элемент в $_, выполнить блок кода, вызвать update_progress(100)
, извлечь из стека последний элемент. Тогда в существующих циклах достаточно заменить for
на for_progress
, перетащить список в конец (как в map
) и присвоить вашей переменной $_, если вы использовали другую переменную. Замечу, что next
и last
продолжат работать (хотя с варнингом), так как внутри for_progress
обычный for
. Простейший тест выглядит так:init_progress;
for_progress {sleep(1)} 1..10;
Так как update_progress
вызывается в конце блока автоматом, в цикле его можно вообще не вызывать. Однако если каждая итерация длинная, вы можете его использовать, указывая проценты выполнения текущей итерации. Разумеется, работают вложенные циклы, использование sub_progress
внутри for_progress
и наоборот. Вот простенький пример:sub A {
for_progress {
sleep(1);
} 1..4;
}
sub B {
sleep(1);
update_progress 10;
sub_progress {A} 50;
sleep(1);
update_progress 60;
sleep(2);
update_progress 80;
sleep(2);
}
init_progress;
sub_progress {A} 25;
sub_progress {A} 50;
sub_progress {B} 100;
Современное программирование трудно представить без слов map
и reduce
. Для них также написаны обёртки map_progress
и reduce_progress
:init_progress;
print "\nSum of cubes from 1 to 1000000 = ".
reduce_progress {$a + $b*$b*$b} 1..1000000;
Тут, конечно, встаёт вопрос производительности: итерация слишком коротка, и вызов обновления прогресс-индикатора каждый раз замедлит процесс на порядки. update_progress
учитывает это и вызывает рендерер не каждый раз, а только когда посчитает нужным: если проценты достигли 100, достаточно сильно изменились или прошло достаточно времени с последнего обновления (всё настраивается параметрами init_progress
). Кроме того сделаны дополнительные оптимизации, в результате чего у меня пример с reduce_progress
выполняется «всего» в 4.5 раза медленнее, чем с List::Util::reduce
. Для очень коротких итераций применять осторожно.Где взять
Первую версию модуля
Progress::Stack
я положил в CPAN. Пока заявку на namespace не утвердили, но пакет можно скачать с сайта CPAN'а. Помимо описанных здесь возможностей там есть ещё кое-что, включая объектный интерфейс (хотя он не особо нужен) и функцию file_progress
для обработки текстового файла по аналогии с while(<FH>) {}
. В документации есть подробное описание и примеры.Замечания и пожелания приветствуются :-)