Now that we’ve learned how to work with loops and arrays, let’s see if we can solve a more challenging problem using loops and arrays.
Problem Statement
For this example, let’s build a program that matches the following problem statement:
Write a program to calculate weighted grades for a student taking a course. The program should begin by accepting the student’s name and a single positive integer as keyboard input, giving the number of assignments in the course. If the input is invalid, simply print “Invalid Input!” and terminate. All input is from the keyboard.
The program should accept the test scores, as ints between 0 and 100, and the test weights, in order, as floats between 0. and 1. The input pattern will be: test 1, test weight, test 2, test weight 2, …
If an out of range score or weight is input, the program should simply print “Invalid Input!” and terminate. If too many tests are entered, print “Invalid Input!” and terminate.
The program should ensure that the total sum of the weights is equal to 1.0. If the weights do not add to 1.0, The program should print “Invalid Input!” and terminate.
The program should print out the the student’s final score. For each assignment, multiply the score and the weight, and then add that value to the total score for the student. The print out should be of the form
<name> earned a ##.##
orTrace earned a 78.86
. UseString.format("%s earned a %5.2f",<name>, <final_score>)
to create the string for printing.
Class Diagram
We’ll start by roughing out a Driver and Object UML class diagram. Since instances are about the data, lets put all the data and logic about the student and exams in a Student
class. Next lets describe the methods which might be useful access and manipulate that data.
-
The attributes would include a student’s name and arrays of their exam scores and and weights. To make arrays we will need to track the “maximum” number of test for a student and the current number of tests stored in the arrays.
-
The methods would include
- a constructor that accepts as parameter’s the name and maximum number of tests.
- a method to input a single tests score and weight, with a way to signal if some invalid input has happened
- a method to check if the test weights sum to 1
- a method to calculate the final score
- a
toString
method that returns<name> earned a ##.##
Let’s have the input method return true
if everything with reading and storing the data goes ok, and false
if any invalid data is used.
We’ll actually run this program with a Driver
class, which will perform the following actions:
- takes input for the name and number of tests
- creates a Student object
- calls the appropriate methods the right number of times to:
- enter the correct number of scores and weight
- print out the final score as indicated above
- If any
Student
method indicates invalid input was sent, prints “Invalid Input!” and terminates the program
The Student Class
First, we’ll need to build the skeleton of our class. By convention, all fields come before all methods and constructors come before other methods.
public class Student{
// Instance fields
public String name;
public int numTests;
public int maxTests;
public int[] testScores;
public double[] testWeights;
// Constructor(s)
public Student (String name, int maxTests){
}
// Instance Methods
public boolean addTest(int score, double weight) {
return false;
}
public boolean sumWeights(){
return false;
}
public double calcGrade() {
return 0.0;
}
public String toString() {
return "";
}
}
This class definition, will compile without errors. Note how each method with a return type returns some default value. Each method has the correct signature and this skeleton should pass any structure test.
Start with Constructor
We know the method Student(name: string, tests: int)
on the UML is the constructor. It needs to set up all the instance attributes, of which there are five. It is common in constructors to use parameter names that match instance field names. This necessitates the use of the key word this
in the constructor to disambiguate the identifiers.
public Student (String name, int maxTests){
this.name = name;
this.numTests = 0;
this.maxTests = maxTests;
this.testScores = new int[maxTests];
this.testWeights = new double[maxTests];
}
Here we have initialized the two arrays to the correct size.
A test for this constructor in the Driver
class might be
public static void main(String[] args){
Student test = new Student("Willie", 3);
System.out.println(test.name);
System.out.println(test.maxTests);
System.out.println(test.numTests);
System.out.println(test.testScores.length);
System.out.println(test.testWeights.length);
}
You should be able to determine the values which should print from the code.
AddTest Method
First we need to check a few things when arriving in the addTest
method. First, we need to make sure the arrays are not at full. This is the purpose of the numTests
attribute. The numTests
attribute starts at 0, and is incremented each time a test is added. Thus as long as numTests < maxTests
, there is still room in the array of another test.
If there is room, we need to ensure that the parameters are in range. If we see a problem in any of these areas, return false. Assuming the parameters are in range, add them to the arrays and increment numTests
.
Finally, if the list of weights is now full, we need to check that it sums to 1.0
. We have a method, sumWeights()
which performs this check, so we just call it.
public boolean addTest(int score, double weight) {
// check that we can add another test
if (numTests < maxTests) {
// check if score and weight are valid
if (score < 0 || score > 100 || weight < 0.0 || weight > 1.0) {
// return false if an error occurs
// the Driver class will print the error
return false;
}
// add the score and weight to the arrays
testScores[numTests] = score;
testWeights[numTests] = weight;
numTests++;
// check if arrays are full
if (numTests == maxTests) {
// if so, return an error if the sum is invalid
return sumWeights();
}
// everything is good, so return true
return true;
} else {
// cannot add a test, so return false
return false;
}
}
In our Driver
class, we might call this method in this way:
Student student = new Student("Willie", 3);
if (!student.addTest(80, 0.25)){
System.out.println("Invalid Input!");
return;
}
If there is a problem in processing the parameters, the addTest()
method should return false
. So, if the method returns true
no error will be printed, but if it returns false
then we will enter the if-statement and print an error instead.
SumWeights Method
This method is a great example of the accumulator pattern when working with arrays. In the accumulator pattern we typically use an enhanced for loop (a for each loop) to iterate through each element in the array/. Inside of the loop, we compute some value across all of those array elements, such as the sum, maximum, or average.
Finally, recall that double
data types are subject to some minor representation error, so when we test to see if the value is exactly 1.0
we should really check if it is within a very small range of values between 0.99999999
and 1.000000001
or similar.
public boolean sumWeights(){
double sum = 0.0;
for(double d : testWeights){
sum += d;
}
// check if sum is very close to 1.0
return 0.99999999 < sum && sum < 1.000000001;
}
CalcGrade Method
Here we use the accumulator pattern again to sum up the contribution of each test (testScores[i]
* testWeights[i]
). We can do this because we entered the scores and weights in order; so testScores[0]
is matched with testWeights[1]
. However, since we are using two different arrays, we cannot use an enhanced for loop, and must instead us a standard for loop, as we’ll see in the code.
This practice is called the parallel array pattern. Each array has different type of data, but each matching index in every array is related in some way. This is a fairly common pattern in non-object-oriented programming. Later in this class, we’ll see how to use our own classes to better store this data in an object-oriented way.
public double calcGrade() {
double sum = 0.0;
for (int i = 0; i < numTests; i++) {
// multiply the score by the weight and add to total
sum += testScores[i] * testWeights[i];
}
return sum;
}
ToString Method
Here we use the given string format String.format("%s earned a %5.2f",<name>, <final_score>)
to create our output. So, we just compute the final grade and return that string:
public String toString(){
double sum = calcGrade();
return String.format("%s earned a %5.2f", name, sum);
}
At this point, our Student
class should be complete. Feel free to stop a minute and test it by writing your own code in the Driver
class before moving on.
Driver Class
The Driver
class should perform the following steps:
- take input for the name and number of tests
- create a
Student
object - call the appropriate methods the right number of times to:
- enter the correct number of scores and weight
- print out the final score as indicated above
- If any
Student
method indicates invalid input was sent, print “Invalid Input!” and terminate the program
Since the Driver
class only has a main
method, we can start with this skeleton:
public class Driver{
public static void main(String[] args) {
}
}
From there, we need to add the code to create a Scanner
object to read input from the terminal. This involves importing the java.util.Scanner
library, and then instantiating a Scanner
inside of the main
method to read from System.in
:
import java.util.Scanner;
public class Driver{
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
}
}
Next, we can start by reading the name of the student and the number of tests from the terminal, and then instantiating our new Student
object using that data:
import java.util.Scanner;
public class Driver{
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.print("Enter the student's name: ");
String name = scanner.nextLine();
System.out.print("Enter the total number of tests: ");
int maxTests = Integer.parseInt(scanner.nextLine());
Student s = new Student(name, maxTests);
}
}
Now that we have created our student, we can read the input for each score and weight and slowly populate that Student
object using that information. If any of those methods returns false
we know that we’ve encountered an error and have to stop the program.
import java.util.Scanner;
public class Driver{
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.print("Enter the student's name: ");
String name = scanner.nextLine();
System.out.print("Enter the total number of tests: ");
int maxTests = Integer.parseInt(scanner.nextLine());
Student s = new Student(name, maxTests);
for (int i = 0; i < maxTests; i++) {
System.out.print("Enter score " + (i+1) + ": ");
int score = Integer.parseInt(scanner.nextLine());
System.out.print("Enter weight " + (i+1) + ": ");
double weight = Double.parseDouble(scanner.nextLine());
if (!s.addTest(score, weight)) {
System.out.println("Invalid Input!");
return;
}
}
}
}
Finally, if we’ve entered all of the scores, we can just call the toString
method of the Student
class to print the final score earned by that student:
import java.util.Scanner;
public class Driver{
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.print("Enter the student's name: ");
String name = scanner.nextLine();
System.out.print("Enter the total number of tests: ");
int maxTests = Integer.parseInt(scanner.nextLine());
Student s = new Student(name, maxTests);
for (int i = 0; i < maxTests; i++) {
System.out.println("Enter score " + (i+1) + ": ");
int score = Integer.parseInt(scanner.nextLine());
System.out.println("Enter weight " + (i+1) + ": ");
double weight = Double.parseDouble(scanner.nextLine());
if (!s.addTest(score, weight)) {
System.out.println("Invalid Input!");
return;
}
}
System.out.println(s.toString());
}
}
A sample execution of this program might look like this: