Yet another take on (ab?)using the power of Java 8 streams...
@200_success's solution treats each line as a double[]
array of scores, which works well enough. Taking the idea one step further, we can write our own Collector
to directly map each score into a Result
object, with references to a student and assignment.
public final class Result {
private final int student;
private final int assignment;
private final double score;
public Result(int student, int assignment, double score) {
this.student = student;
this.assignment = assignment;
this.score = score;
}
public int getStudent() {
return student;
}
public int getAssignment() {
return assignment;
}
public double getScore() {
return score;
}
}
I suppose the Result
class is straight-forward...
public final class ScoreCollector implements
Collector<String, Collection<Result>, Collection<Result>> {
private int numStudents = -1;
private int numAssignments = -1;
private int currentStudent = 0;
@Override
public Supplier<Collection<Result>> supplier() {
return ArrayList::new;
}
@Override
public BiConsumer<Collection<Result>, String> accumulator() {
return (collection, line) -> {
if (isRecord(line)) {
collection.addAll(toResults(line));
}
};
}
@Override
public BinaryOperator<Collection<Result>> combiner() {
return null;
}
@Override
public Function<Collection<Result>, Collection<Result>> finisher() {
return accumulator -> accumulator;
}
@Override
public Set<Characteristics> characteristics() {
return EnumSet.noneOf(Characteristics.class);
}
private boolean isRecord(final String line) {
if (numStudents == -1) {
numStudents = Integer.parseInt(line);
return false;
} else if (numAssignments == -1) {
numAssignments = Integer.parseInt(line);
return false;
}
return true;
}
private Collection<Result> toResults(final String line) {
final int student = ++currentStudent;
final int[] assignment = new int[1];
return Arrays.stream(line.split("\\s", numAssignments))
.map(v -> new Result(student, ++assignment[0], Double.parseDouble(v)))
.collect(Collectors.toList());
}
}
Our custom ScoreCollector
has to perform a few things:
- Parse and effectively ignore the first (number of students) and second (number of assignment) lines, optionally validating the later steps against these two values.
- Convert a line into a
Collection<Result>
while being aware of the current student (i.e. line) and assignment.
- Accumulate the resulting
Collection<Result>
into a final Collection<Result>
.
- Returning the final
Collection<Result>
.
The accumulator()
method performs the first two steps, through isRecord()
validation and then converting with toResults()
. Setting instance variables in isRecord()
does seem a bit odd, I'm actually open to comments on this...
Referencing of the current student and assignment is squarely done in toResults()
, by incrementing on a currentStudent
variable and a temporary one-element assignment
array. In the code above, line.split()
is called with the second argument numAssignments
, which is the total number of expected scores per student. This will expectedly throw an Exception
if we have mismatched number of scores. You can omit this argument for flexibility.
Things get easier after coding ScoreCollector
. The benefits of dealing with just a single Collection<Result>
is that you can apply more streaming functions on it based on the values encapsulated in each Result
object. For illustration, I went with an alternative output of showing the maximum and minimum scores per student and per assignment. The only calculation I did not derive is the "overall average", as I'm not too sure how that will be interpreted. Anyways, code please:
private static final String FORMAT = "%s #%d\tAverage: %.2f\tMax: %.2f\tMin: %.2f%n";
public static void main(String[] args) {
try (final Stream<String> lines = Files.lines(Paths.get(ClassLoader.getSystemResource(
"scores.txt").toURI()))) {
final Collection<Result> result = lines.collect(new ScoreCollector());
groupAndPrint(result, Result::getStudent, "Student");
groupAndPrint(result, Result::getAssignment, "Assignment");
} catch (IOException | URISyntaxException e) {
throw new RuntimeException(e);
}
}
@SuppressWarnings("boxing")
private static void groupAndPrint(final Collection<Result> collection,
final Function<Result, ?> classifier, final String label) {
collection
.stream()
.collect(
Collectors.groupingBy(classifier,
Collectors.summarizingDouble(Result::getScore)))
.forEach(
(key, summary) -> {
System.out.printf(FORMAT, label, key, summary.getAverage(),
summary.getMax(), summary.getMin());
});
}
I am using try-catch
on opening the file using Java 8's new Files.lines()
method, and from that Stream
of lines
, we collect using our ScoreCollector
. We then use a helper method groupAndPrint
to group by our Collection<Result>
with the desired classifer, which will be either student (Result::getStudent
) or assignment (Result::getAssignment
) in our case. Afterwards, it's just a quick call to Collectors.summarizingDouble()
on our scores, and we can easily print out the average, maximum and minimum scores per student/assignment.
Student #1 Average: 88.03 Max: 100.00 Min: 67.00
Student #2 Average: 95.32 Max: 100.00 Min: 82.20
Student #3 Average: 84.17 Max: 100.00 Min: 74.00
Student #4 Average: 59.72 Max: 98.60 Min: 0.00
Student #5 Average: 93.53 Max: 100.00 Min: 81.30
Assignment #1 Average: 98.76 Max: 100.00 Min: 95.20
Assignment #2 Average: 89.14 Max: 100.00 Min: 71.50
Assignment #3 Average: 90.40 Max: 100.00 Min: 68.90
Assignment #4 Average: 80.72 Max: 100.00 Min: 62.40
Assignment #5 Average: 83.92 Max: 100.00 Min: 56.90
Assignment #6 Average: 61.98 Max: 82.20 Min: 0.00
I hope my usage of Java 8 streams is still a somewhat sane solution to your question, with more flexibility for alternative outputs...