Простое задание на обработку отдельной
последовательности: LinqObj4
Создание проекта-заготовки и ознакомление с заданием.
Дополнительные средства окна задачника, связанные
с просмотром файловых данных
Задания группы LinqObj предназначены для закрепления навыков
применения различных методов интерфейса LINQ to Objects. В отличие от
заданий группы LinqBegin эти задания не ориентированы на изучение
какого-либо отдельного вида запросов; при их выполнении требуется
самостоятельно выбрать методы LINQ, обеспечивающие получение
требуемого результата. Еще одним отличием от заданий группы LinqBegin
является более сложный вид исходных последовательностей: их элементы
представляют собой записи, состоящие из нескольких полей. Указанные
особенности приближают задания группы LinqObj к реальным задачам,
возникающим при обработке сложных структур данных.
Мы рассмотрим сравнительно простую задачу, связанную с
обработкой отдельной последовательности.
LinqObj4°. Исходная последовательность содержит сведения о клиентах фитнес-центра. Каждый элемент последовательности включает следующие целочисленные поля: <Год> <Номер месяца> <Продолжительность занятий (в часах)> <Код клиента> Для каждого клиента, присутствующего в исходных данных, определить суммарную продолжительность занятий в течение всех лет (вначале выводить суммарную продолжительность, затем код клиента). Сведения о каждом клиенте выводить на новой строке и упорядочивать по убыванию суммарной продолжительности, а при их равенстве — по возрастанию кода клиента.
Проект-заготовка для данного задания, как и для любых заданий,
выполняемых с использованием электронного задачника Programming
Taskbook, должен создаваться с помощью программного модуля PT4Load.
После создания этого проекта, автоматического запуска
среды Visual Studio и загрузки в нее созданного проекта на экране
будет отображен файл LinqObj4.cs, в который требуется ввести решение
задачи. Приведем содержимое этого файла:
// File: "LinqObj4"
using PT4;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
namespace PT4Tasks
{
public class MyTask : PT
{
// To read strings from the source text file into
// a string sequence (or array) s, use the statement:
// s = File.ReadLines(GetString());
// To write the sequence s of IEnumerable<string> type
// into the resulting text file, use the statement:
// File.WriteAllLines(GetString(), s);
// When solving tasks of the LinqObj group, the following
// additional methods defined in the taskbook are available:
// (*) Show() and Show(cmt) (extension methods) - debug output
// of a sequence, cmt - string comment;
// (*) Show(e => r) and Show(cmt, e => r) (extension methods) -
// debug output of r values, obtained from elements e
// of a sequence, cmt - string comment.
public static void Solve()
{
Task("LinqObj4");
}
}
}
По сравнению с файлами, создаваемыми для решения задач группы LinqBegin,
файлы-заготовки для задач группы LinqObj имеют следующие особенности:
- в список директив using добавлена директива подключения пространства
имен System.IO, в котором определены классы, связанные с файловым вводом-выводом;
- класс MyTask содержит комментарий, описывающий действия,
которые надо выполнить для ввода исходных последовательностей
и вывода результатов.
При выполнении заданий из группы LinqObj нет необходимости в использовании
методов ввода-вывода GetEnumerableInt, GetEnumerableString
и Put, поскольку как исходные, так и результирующие последовательности
содержатся в текстовых файлах, для работы с которыми достаточно
использовать средства стандартной библиотеки .NET. Из числа
дополнительных методов ввода-вывода, предоставляемых задачником, в
заданиях группы LinqObj требуется использовать только методы,
предназначенные для ввода целых чисел и строк: GetInt и GetString
соответственно.
Запустив созданную программу-заготовку, мы увидим на экране окно
задачника, содержащее формулировку задания, а также пример исходных
данных и правильных результатов:
Как при ознакомительном запуске любого задания, окно содержит три
раздела: с формулировкой задания, исходными данными и примером
верного решения. В начале раздела с исходными данными указаны две
текстовые строки, снабженные комментариями «Исходный файл» и «Файл
результатов». Первая строка определяет имя текстового файла,
содержащего исходную последовательность. Этот файл автоматически
создается задачником при инициализации задания; получив его имя,
программа учащегося сможет обратиться к нему и прочесть его данные.
Вторая строка определяет имя текстового файла, в котором должна
содержаться результирующая строковая последовательность. Программа
учащегося должна заполнить указанный файл требуемыми данными, после
чего задачник проанализирует содержимое этого файла, сравнив его с правильным вариантом решения.
При каждом тестовом запуске имена файлов, как и их
содержимое, изменяются.
В окне задачника отображаются не только имена, но и содержимое
файлов, связанных с заданием. Каждая строка текстового файла
заключается в кавычки и выводится на отдельной экранной строке; рядом
с первой строкой файла указывается ее порядковый номер, равный 1. В
разделах исходных данных и результатов содержимое файлов выделяется
бирюзовым цветом (цветовое выделение позволяет отличить файловые
строки от других данных, а также комментариев). В разделе с правильным
решением все данные выводятся серым цветом, чтобы отличить их от
«настоящих» данных, найденных программой учащегося.
Приведенный на предыдущем рисунке вариант окна соответствует режиму
сокращенного отображения файловых данных, в котором для каждого
файла отображается лишь начальная часть его содержимого (от одной до
пяти строк). Признаком того, что часть данных отсутствует, является
многоточие, размещенное в нижней части тех разделов, в которых
отображаются сокращенные данные. Этот режим удобен при
первоначальном знакомстве с заданием, поскольку позволяет отобразить в
окне сравнительно небольшого размера содержимое всех разделов. При
более детальном анализе данных, а также при сравнении полученных
ошибочных результатов с примером правильного решения следует
использовать режим полного отображения содержимого текстовых файлов.
Завершающая часть данного раздела будет посвящена описанию
различных возможностей, связанных с режимом полного отображения.
Для переключения между сокращенным и полным режимом
отображения файловых данных достаточно нажать клавишу [Ins] или
выполнить двойной щелчок мышью в одном из разделов с файловыми
данными. Можно также щелкнуть на квадратном маркере, который
появляется в правом верхнем углу раздела исходных данных, если окно
содержит файловые данные. Изображение на этом маркере служит
индикатором режима: вариант со стрелкой, направленной вниз,
обозначает режим сокращенного отображения (при наведении мыши на
маркер в этом случае выводится подсказка «Развернуть содержимое
текстовых файлов (Ins)»); вариант со стрелкой, направленной вверх,
обозначает режим полного отображения (с ним связана подсказка
«Свернуть содержимое текстовых файлов (Ins)»).
На следующем рисунке приведен вид окна в режиме полного отображения
текстовых файлов. В этом режиме порядковый номер указывается перед
каждой файловой строкой. При закрытии окна текущий режим
отображения запоминается и при последующих запусках программы
восстанавливается.
В случае, когда размеров окна недостаточно для отображения всех
данных, окно снабжается полосой прокрутки, и, кроме того, на нем
отображаются дополнительные маркеры (см. рисунок). Прокрутку
содержимого окна проще всего выполнять с помощью клавиш [Home],
[End], [Up], [Down], [PgUp], [PgDn], а также используя колесико мыши.
Группа маркеров, отображаемая в левом верхнем углу раздела с
формулировкой задания, предназначена для быстрого отображения
различных разделов, связанных с заданием: щелчок на маркере
обеспечивает переход к началу следующего раздела (см. рисунок), щелчок на
маркере к началу предыдущего раздела. Перебор разделов
выполняется циклически. Маркер позволяет переключаться между
разделами с результатами и примером правильного решения, если окно
содержит оба этих раздела. Вместо щелчка на указанных маркерах
достаточно нажать соответствующую клавишу: [+], [] или [/].
Наконец, отметим маркер , появляющийся в правом верхнем углу
раздела с формулировкой задания при отображении в окне полосы
прокрутки. Этот маркер (и связанная с ним клавиша [Del]) позволяет
скрыть раздел с формулировкой, увеличив тем самым область окна для
отображения данных из других разделов. Заметим, что клавиша [Del]
позволяет скрыть раздел с формулировкой и в том случае, если маркер
не отображается на экране.
Выполнение задания
Завершив обзор дополнительных возможностей окна задачника и
ознакомившись с заданием LinqObj4, приступим к его выполнению.
В этом задании, как и во всех заданиях группы LinqObj, исходная
последовательность содержится во внешнем текстовом файле. Для чтения
всех строк этого файла проще всего воспользоваться методом ReadLines
класса File, который возвращает содержимое файла в виде последовательности
IEnumerable<string>:
File.ReadAllLines(GetString())
Первый параметр этого метода определяет имя файла; мы получаем
это имя с помощью функции GetString. Имеется также второй, необязательный параметр,
который определяет кодировку файла; по умолчанию используется кодировка
UTF-8. Начиная с версии 1.3 задачника PT for LINQ, во всех заданиях данной группы исходные файлы содержат только символы
из набора ASCII, кодировка которых совпадает с кодировкой UTF-8, поэтому второй параметр указывать не требуется.
При последующей обработке исходной последовательности нам
будет нужно обращаться к отдельным полям ее элементов, поэтому после
считывания строк из исходного файла необходимо преобразовать их в
набор полей. Для этого достаточно применить к полученному строковому
массиву метод Select, определяющий по исходному строковому элементу
элемент анонимного типа с требуемыми полями:
File.ReadLines(GetString())
.Select(e =>
{
string[] s = e.Split(' ');
return new
{
hours = int.Parse(s[2]),
code = int.Parse(s[3])
};
})
Мы использовали лямбда-выражение, содержащее не возвращаемое
значение, а набор операторов, включающий оператор return. Это связано с
тем, что для определения полей надо предварительно выполнить
расщепление исходной строки методом Split. Заметим, что если разбить
текст лямбда-выражения на отдельные строки, то редактор кода
автоматически выполнит форматирование полученного текста, представив
результат в виде, подобном приведенному выше.
Возможен и вариант лямбда-выражения с возвращаемым значением,
но в нем требуется дважды выполнить вызов метода Split:
.Select(e => new
{
hours = int.Parse(e.Split[2]),
code = int.Parse(e.Split[3])
})
Такой вариант вполне допустим для простых наборов данных,
содержащих небольшое число полей.
Заметим, что мы включили в анонимный тип лишь данные, связанные
с продолжительностью занятий (поле hours ) и кодом клиента (поле code ),
поскольку сведения о годе и месяце для выполнения задания LinqObj4 не
требуются.
На данном этапе решения задачи можно выполнить отладочную
печать полученной последовательности, добавив к цепочке вызванных
методов вспомогательный метод Show. Приведем текст функции Solve,
соответствующий текущему этапу решения:
public static void Solve()
{
Task("LinqObj4");
File.ReadLines(GetString())
.Select(e =>
{
string[] s = e.Split(' ');
return new
{
hours = int.Parse(s[2]),
code = int.Parse(s[3])
};
})
.Show();
}
При запуске программы в окне задачника появится раздел отладки,
содержащий информацию о полученной последовательности. Приведем
вид этого окна в режиме сокращенного отображения файловых данных,
дополнительно скрыв раздел с формулировкой. Информационная
панель содержит сообщение «Введены не все требуемые исходные
данные», поскольку в нашей программе еще не введено имя файла
результатов.
Раздел отладки демонстрирует, каким образом данные анонимного типа
преобразуются к их строковому представлению: это представление
содержит список пар имя поля = значение поля , заключенный в фигурные
скобки. Сравнивая исходные строковые данные и данные, выведенные в
разделе отладки, убеждаемся, что преобразование выполнено правильно.
В задании требуется определить суммарную продолжительность
занятий для каждого клиента; для этого следует выполнить группировку
полученной последовательности по кодам клиентов. Затем надо
отсортировать сгруппированную последовательность по двум
ключам: главному (суммарная продолжительность) по убыванию и
подчиненному (код клиента) по возрастанию. Отсортированную
последовательность надо преобразовать в последовательность строк с
помощью метода проецирования Select.
Используя простейший вариант метода GroupBy, получаем
следующую цепочку методов, которая должна продолжить ранее
построенную цепочку:
.GroupBy(e => e.code)
.OrderByDescending(e => e.Sum(c => c.hours))
.ThenBy(e => e.Key)
.Select(e => e.Sum(c => c.hours) + " " + e.Key)
В приведенном варианте приходится дважды вычислять сумму полей
hours : при сортировке и при последующем проецировании. Чтобы этого
избежать, можно использовать вариант метода GroupBy с двумя
параметрами, определив для элементов сгруппированной
последовательности новый анонимный тип:
.GroupBy(e => e.code,
(k, ee) => new {k, sum = ee.Sum(c => c.hours)})
.OrderByDescending(e => e.sum).ThenBy(e => e.k)
.Select(e => e.sum + " " + e.k)
В выражении, использованном в методе Select, не требуется
выполнять специальные действия по преобразованию числовых данных в
их строковые представления, так как, согласно правилам языка C#,
подобное преобразование выполняется автоматически для всех операндов
суммы, если хотя бы один из этих операндов имеет тип string (в нашем
случае таким операндом является разделитель-пробел).
Осталось записать полученную строковую последовательность в
текстовый файл с указанным именем. Если предварительно связать
последовательность с переменной r , то для сохранения ее в текстовом
файле достаточно выполнить следующий оператор (напомним, что этот
оператор указан в комментарии, включенном в заготовку к заданию):
File.WriteAllLines(GetString(), r);
При выполнении данного оператора вначале определяется имя файла результатов, которое считывается
из набора исходных данных с помощью функции GetString, а затем
выполняется запись элементов строковой последовательности в файл; при этом по умолчанию
используется кодировка UTF-8.
Объединяя полученные фрагменты, получаем первый вариант
правильного решения:
public static void Solve()
{
Task("LinqObj4");
var r = File.ReadLines(GetString())
.Select(e =>
{
string[] s = e.Split(' ');
return new
{
hours = int.Parse(s[2]),
code = int.Parse(s[3])
};
})
.GroupBy(e => e.code,
(k, ee) => new { k, sum = ee.Sum(c => c.hours) })
.OrderByDescending(e => e.sum).ThenBy(e => e.k)
.Select(e => e.sum + " " + e.k);
File.WriteAllLines(GetString(), r);
}
Для проверки правильности полученного решения (как и решений
других задач группы LinqObj) его надо протестировать на девяти
различных тестовых наборах исходных данных, среди которых будут
встречаться и наборы сравнительно большого размера. В качестве примера
приведем окно задачника с сообщением об успешном выполнении задания,
в котором для обработки был предложен набор из 86 записей:
Завершая обсуждение задания LinqObj4, приведем вариант его
решения, использующий выражение запросов:
public static void Solve()
{
Task("LinqObj4");
var r =
from e in File.ReadLines(GetString())
let s = e.Split(' ')
select new
{
hours = int.Parse(s[2]),
code = int.Parse(s[3])
}
into e
group e.hours by e.code
into e
let sum = e.Sum()
orderby sum descending, e.Key
select sum + " " + e.Key;
File.WriteAllLines(GetString(), r);
}
Прокомментируем особенности данного варианта.
Поскольку в конструкции select можно указывать только выражение,
определяющее элемент возвращаемой последовательности, вызов метода
Split мы выполнили в конструкции let, сохранив его результат во
вспомогательной переменной s и использовав его далее в конструкции
select.
Результаты выполнения конструкций select и group (т. е.
промежуточные последовательности) мы передавали в следующие
фрагменты выражения запроса, используя конструкцию продолжения
запроса into. При автоматическом
форматировании выражения запроса, выполняемом редактором среды
Visual Studio, каждый фрагмент, начинающийся с конструкции
продолжения запроса, выделяется дополнительным отступом. Обратите
внимание на то, что при продолжении запроса можно использовать имя
ранее применявшейся переменной-перечислителя, поскольку ранее
введенные перечислители в последующей части выражения запроса
недоступны.
Конструкция group, в отличие от метода GroupBy, не позволяет явно
определять вид элементов получаемой последовательности (в результате
группировки всегда возвращается последовательность элементов типа
IGrouping<K, E>), однако имеется возможность явно указать,
какие поля из исходного набора следует оставить в сгруппированной
последовательности. Указав после слова group поле e.hours , мы
обеспечили построение последовательности элементов, включающих ключ
(свойство Key) и числовую последовательность набор чисел, взятых из
полей hours и соответствующих данному ключу. Это позволило при
вызове метода Sum обойтись без уточняющего лямбда-выражения. Для
того чтобы избежать двойного вызова метода Sum, мы сохранили его
результат во вспомогательной переменной sum , которая была определена с
помощью еще одной конструкции let.
Полученный вариант решения обладает несколько большей
наглядностью, чем первый вариант, прежде всего, за счет отсутствия
лямбда-выражений.
|