分享

Validate state with class invariants

 燮羽 2010-11-27
   
   Class invariants are methods which check the validity of an object's state (its data). The idea is to define validation methods for fields, and to perform these validations whenever the fields change. As usual, this should be done without repeating any code.

An object's state may become invalid for various reasons :

An invalid argument is passed by the caller. 
To ensure that the caller fulfills the requirements of a method or constructor, all arguments to non-private methods should be explicitly checked. An Exception should be thrown if a problem is detected. A special case is deserialization, which should treatreadObject like a constructor. (Assertions should not be used for these types of checks.)

The implementation of the class is defective. 
As a defensive measure, a method which changes the state of an object can include an assertion at its end, to check that the object has indeed remained in a valid state. Here, such assertions verify correctness of internal implementation details - they do not check arguments.

Example 1

Resto is an immutable Model Object (MO). Its class invariant is defined by the validateState method. In this particular case, if validation fails a checked exception is thrown, and the end user is presented with their original input, along with associated error messages.

Such immutable classes represent the simplest case, since validation is performed only once, during construction (and deserialization, if necessary). By definition, an immutable object cannot change state after construction, so performing validations at other times in the object's life is never necessary. 

package hirondelle.fish.main.resto;
import hirondelle.web4j.model.ModelCtorException;
import hirondelle.web4j.model.ModelUtil;
import hirondelle.web4j.model.Id;
import hirondelle.web4j.security.SafeText;
import hirondelle.web4j.model.Decimal;
import static hirondelle.web4j.model.Decimal.ZERO;
import hirondelle.web4j.model.Check;
import hirondelle.web4j.model.Validator;
import static hirondelle.web4j.util.Consts.FAILS;
/** Model Object for a Restaurant. */
public final class Resto {
/**
Full constructor.
@param aId underlying database internal identifier (optional) 1..50 characters
@param aName of the restaurant (required), 2..50 characters
@param aLocation street address of the restaurant (optional), 2..50 characters
@param aPrice of the fish and chips meal (optional) $0.00..$100.00
@param aComment on the restaurant in general (optional) 2..50 characters
*/
public Resto(
Id aId, SafeText aName, SafeText aLocation, Decimal aPrice, SafeText aComment
) throws ModelCtorException {
fId = aId;
fName = aName;
fLocation = aLocation;
fPrice = aPrice;
fComment = aComment;
validateState();
}
public Id getId() { return fId; }
public SafeText getName() {  return fName; }
public SafeText getLocation() {  return fLocation;  }
public Decimal getPrice() { return fPrice; }
public SafeText getComment() {  return fComment; }
@Override public String toString(){
return ModelUtil.toStringFor(this);
}
@Override public  boolean equals(Object aThat){
Boolean result = ModelUtil.quickEquals(this, aThat);
if ( result ==  null ) {
Resto that = (Resto) aThat;
result = ModelUtil.equalsFor(
this.getSignificantFields(), that.getSignificantFields()
);
}
return result;
}
@Override public int hashCode(){
if ( fHashCode == 0 ){
fHashCode = ModelUtil.hashCodeFor(getSignificantFields());
}
return fHashCode;
}
// PRIVATE //
  private final Id fId;
private final SafeText fName;
private final SafeText fLocation;
private final Decimal fPrice;
private final SafeText fComment;
private int fHashCode;
private static final Decimal HUNDRED = Decimal.from("100");
private void validateState() throws ModelCtorException {
ModelCtorException ex = new ModelCtorException();
if ( FAILS == Check.optional(fId, Check.range(1,50)) ) {
ex.add("Id is optional, 1..50 chars.");
}
if ( FAILS == Check.required(fName, Check.range(2,50)) ) {
ex.add("Restaurant Name is required, 2..50 chars.");
}
if ( FAILS == Check.optional(fLocation, Check.range(2,50)) ) {
ex.add("Location is optional, 2..50 chars.");
}
Validator[] priceChecks = {Check.range(ZERO, HUNDRED), Check.numDecimalsAlways(2)};
if ( FAILS == Check.optional(fPrice, priceChecks)) {
ex.add("Price is optional, 0.00 to 100.00.");
}
if ( FAILS == Check.optional(fComment, Check.range(2,50))) {
ex.add("Comment is optional, 2..50 chars.");
}
if ( ! ex.isEmpty() ) throw ex;
}
private Object[] getSignificantFields(){
return new Object[] {fName, fLocation, fPrice, fComment};
}
}


Example 2

Here is an example of a mutable, Serializable class which defines class invariants.

Items to note :

  • the assertion at the end of the close method
  • the call to validateState at the end of the readObject method
  • the implementation is significantly more complex, since the class is mutable

import java.text.StringCharacterIterator;
import java.util.*;
import java.io.*;
/**
* In this style of implementation, both the entire state of the object
* and its individual fields are validated without duplicating any code.
*
* Argument validation usually has if's and thrown exceptions at the
* start of a method. Here, these are replaced with a simple
* call to validateXXX. Validation is separated cleanly from the
* regular path of execution, improving legibility.
*/
public final class BankAccount implements Serializable {
/**
* @param aFirstName contains only letters, spaces, and apostrophes.
* @param aLastName contains only letters, spaces, and apostrophes.
* @param aAccountNumber is non-negative.
*
* @throws IllegalArgumentException if any param does not comply.
*/
public BankAccount( String aFirstName, String aLastName, int aAccountNumber) {
//don't call an overridable method in a constructor
      setFirstName(aFirstName);
setLastName(aLastName);
setAccountNumber(aAccountNumber);
}
/**
* All "secondary" constructors call the "primary" constructor, such that
* validations are always performed.
*/
public BankAccount() {
this ("FirstName", "LastName", 0);
}
public String getFirstName() {
return fFirstName;
}
public String getLastName(){
return fLastName;
}
public int getAccountNumber() {
return fAccountNumber;
}
/**
* This method changes state internally, and may use an assert to
* implement a post-condition on the object's state.
*/
public void close(){
//valid:
     fAccountNumber = 0;
//this invalid value will fire the assertion:
     //fAccountNumber = -2;

assert hasValidState(): this;
}
/**
* Names must contain only letters, spaces, and apostrophes.
*
* @throws IllegalArgumentException if any param does not comply.
*/
public void setFirstName( String aNewFirstName ) {
validateName(aNewFirstName);
fFirstName = aNewFirstName;
}
/**
* Names must contain only letters, spaces, and apostrophes.
*
* @throws IllegalArgumentException if any param does not comply.
*/
public void setLastName ( String aNewLastName ) {
validateName(aNewLastName);
fLastName = aNewLastName;
}
/**
* AccountNumber must be non-negative.
*
* @throws IllegalArgumentException if any param does not comply.
*/
public void setAccountNumber( int aNewAccountNumber ) {
validateAccountNumber(aNewAccountNumber);
fAccountNumber = aNewAccountNumber;
}
/**
* Can be used to easily pass object description to an assertion,
* using a "this" reference.
*/
public String toString(){
final StringBuilder result = new StringBuilder();
final String SPACE = " ";
result.append(fFirstName);
result.append(SPACE);
result.append(fLastName);
result.append(SPACE);
result.append(fAccountNumber);
return result.toString();
}
/// PRIVATE /////

private String fFirstName;
private String fLastName;
private int fAccountNumber;
/**
* Verify that all fields of this object take permissible values; that is,
* this method defines the class invariant.
*
* Call after deserialization.
* @throws IllegalArgumentException if any field takes an unpermitted value.
*/
private void validateState() {
validateAccountNumber(fAccountNumber);
validateName(fFirstName);
validateName(fLastName);
}
/**
* Return true if <code>validateState</code> does not throw
* an IllegalArgumentException, otherwise return false.
*
* Call at the end of any public method which has changed
* state (any "mutator" method). This is usually done in
* an assertion, since it corresponds to a post-condition.
* For example,
* <pre>
* assert hasValidState() : this;
* </pre>
* This method is provided since <code>validateState</code> cannot be used
* in an assertion.
*/
private boolean hasValidState() {
boolean result = true;
try {
validateState();
}
catch (IllegalArgumentException ex){
result = false;
}
return result;
}
/**
* Ensure names contain only letters, spaces, and apostrophes.
*
* @throws IllegalArgumentException if argument does not comply.
*/
private void validateName(String aName){
boolean nameHasContent = (aName != null) && (!aName.equals(""));
if (!nameHasContent){
throw new IllegalArgumentException("Names must be non-null and non-empty.");
}
StringCharacterIterator iterator = new StringCharacterIterator(aName);
char character =  iterator.current();
while (character != StringCharacterIterator.DONE ){
boolean isValidChar = (Character.isLetter(character)
|| Character.isSpaceChar(character)
|| character =='\'');
if ( isValidChar ) {
//do nothing
       }
else {
String message = "Names can contain only letters, spaces, and apostrophes.";
throw new IllegalArgumentException(message);
}
character = iterator.next();
}
}
/**
* AccountNumber must be non-negative.
*
* @throws IllegalArgumentException if argument does not comply.
*/
private void validateAccountNumber(int aAccountNumber){
if (aAccountNumber < 0) {
String message = "Account Number must be greater than or equal to 0.";
throw new IllegalArgumentException(message);
}
}
private static final long serialVersionUID = 7526472295622776147L;
/**
* Always treat de-serialization as a full-blown constructor, by
* validating the final state of the de-serialized object.
*/
private void readObject(ObjectInputStream aInputStream)
throws ClassNotFoundException, IOException {
//always perform the default de-serialization first
     aInputStream.defaultReadObject();
//ensure that object state has not been corrupted or
     //tampered with maliciously
     validateState();
}
/**
* Test harness.
*/
public static void main (String[] aArguments) {
BankAccount account = new BankAccount("Joe", "Strummer", 532);
//exercise specific validations.
    account.setFirstName("John");
account.setAccountNumber(987);
//exercise the post-condition assertion
    //requires enabled assertions: "java -ea"
    account.close();
//exercise the serialization
    ObjectOutput output = null;
try{
OutputStream file = new FileOutputStream( "account.ser" );
OutputStream buffer = new BufferedOutputStream( file );
output = new ObjectOutputStream( buffer );
output.writeObject(account);
}
catch(IOException exception){
System.err.println(exception);
}
finally{
try {
if (output != null) output.close();
}
catch (IOException exception ){
System.err.println(exception);
}
}
//exercise the deserialization
    ObjectInput input = null;
try{
InputStream file = new FileInputStream( "account.ser" );
InputStream buffer = new BufferedInputStream( file );
input = new ObjectInputStream ( buffer );
BankAccount recoveredAccount = (BankAccount)input.readObject();
System.out.println( "Recovered account: " + recoveredAccount );
}
catch(IOException exception){
System.err.println(exception);
}
catch (ClassNotFoundException exception){
System.err.println(exception);
}
finally{
try {
if ( input != null ) input.close();
}
catch (IOException exception){
System.err.println(exception);
}
}
}
} 

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多