Common Lisp idioms for argument checking and other paranoia?
Asked Answered
W

1

18

This question is about coding conventions, best practices, and style in production, mission-critical Common-Lisp code. I perused Google's Common-Lisp Style Guide, but didn't find anything clearly addressing my specific concern, which I express by example and by contrast to C/C++, Java, etc. I also did a quick look around Common-Lisp code bases on Github, and I did not see lots of argument checking and intermediate-value checking, which I do see in C/C++, Java, etc.

In my shop, we're very accustomed to checking arguments & other values and taking early exits when the arguments don't meet contracts / preconditions, etc. For example, consider the following (contrived, imperfect, typical, but please-don't-waste-time-criticizing, micro-example, which presages the CL example):

ErrorCode o_symb_to_g_symb (char * symb, uint len)
{   if (len < 2) { return ERROR_LENGTH; }
    if (symb[0] != 'O' || symb[1] != '!') { return ERROR_SYNTAX; }
    char * result = (char *) malloc (len + 1);
    if (NULL == result) { return ERROR_MALLOC; }
    if (result != strncpy (result, symb, len + 1)) 
    {   return ERROR_STRNCPY;   }
    result[0] = 'G';
    return result;   }

This does approximately the same thing as Doug Hoyte's code from "Let Over Lambda," page 67, only it takes care to check as much as possible along the way (http://letoverlambda.com/).

  (defun o!-symbol-to-g!-symbol (s)
    (symb "G!"
          (subseq (symbol-name s) 2))))

The question is whether real-world production code in Common Lisp does more checking. For example, it might be reasonable to write explicit code to check that s is actually a string and is actually long enough, and actually has an "O!" as its first two characters.

Is this code bypassing all that paranoia just because it's pedagogical? Would the same code in a mission-critical production deployment be more likely to have the paranoia checks (my light canvasing of Github for CL code would suggest "no")? If real-world CL code does not tend to the paranoid, why not? Is the practice of corner-case or exhaustive testing more widespread than it appears?

In short, I'm quite puzzled by the difference in styles. Real-world, mission-critical C code tends to be super-paranoid. I don't see the same in CL. Perhaps I'm not looking at the right code bases? Perhaps I haven't read the right books? An answer to this question doesn't seem to be easy to find by Googling.

Westerfield answered 5/12, 2015 at 15:15 Comment(0)
U
28

Common Lisp is a language designed for development of large and complex applications. What in the 80s were thought to be large applications. But it got from production systems several facilities to deal with errors and even some support for compile-time checking. Still lots of code is written for prototypical software, research systems and/or personal purposes. You don't find always a high level of quality. Also keep in mind that sometimes very strict checking can make a code too strict (example: lots of HTTP clients will send non-conforming requests, but that's how it is and one can't easily reject them without losing a large number of possible users).

Let's look at some examples how Common Lisp helps you to write robust software:

strong typing and runtime type checking

We expect that a usual Lisp system will do runtime checks for each and every operation. Avoid Lisp systems which don't.

If you have a numeric function:

(defun foo (n x)
  ....
    (bar ...))

(defun bar (a b)
  (+ a b))

If FOO does no argument checks, we expect that eventually the + operation will check the arguments. At runtime there will be an error and an error handler will run, which, by default, will call a debugger.

Think about it: all (most) operations will be checked at runtime. All objects have a primitive type tag (integer, string, array, bit vector, character, stream, ...) and at runtime the type will eventually be checked.

But we expect more from the Lisp runtime:

  • array bounds checks
  • slot type checks
  • heap consistency in case of errors
  • various checks against harmful operations like redefining standard functions, deleting the Common Lisp package, arithmetic errors, etc.

Using a Lisp system which does not do runtime type checks is a huge pain. Now, Common Lisp allows us to declare parts of the code not to do runtime checks. Best strategy: find the smallest amount of code where it can be done without creating a risk (see LOCALLY).

Argument lists

Common Lisp allows some argument list checking at compile time. Use it.

(defun foo (&key (n 1) (x 1.0))
  ...)

Now a typical compiler will catch a call like (foo :y 2 :x 2.0) with an error: wrong keyword argument :y.

Let the compiler check that the argument list has the right number of arguments and that the correct keyword arguments are being used.

CLOS, the Common Lisp Object System

Use CLOS.

(defmethod foo ((n integer) (x float)) ...)

If you define a method like above, at runtime in the method body n will be an integer and x will be a float. If you call FOO with other argument types and no methods apply, then we get a runtime error.

Similar for instance slots: you can declare types.

(defclass bar ()
   ((x :type float)
    (n :type integer)))

Use a Common Lisp implementation which actually checks those declarations or write your own checks.

Also: don't create raw data structures based on lists. Always package them into CLOS classes and methods. That way you get the right amount of runtime checking and introspection capabilities.

Check types at runtime

Common Lisp provides a macro for runtime type checking: CHECK-TYPE.

(defun foo (n x)
  (check-type n integer)
  (check-type x float)
  (* (isqrt n) (sqrt x)))

The CHECK-TYPE macro allows fancy type checking and even repairing the error.

CL-USER 27 > (foo 2000 5)

Error: The value 5 of X is not of type FLOAT.
  1 (continue) Supply a new value of X.
  2 (abort) Return to level 0.
  3 Return to top loop level 0.

Type :b for backtrace or :c <option number> to proceed.
Type :bug-form "<subject>" for a bug report template or :? for other options.

CL-USER 28 : 1 > :c 1

Enter a form to be evaluated: 5.0

Note that you can use the types to specify things like the interval for numbers, array dimensions or similar.

For example this checks that an object bound to the variable a1 is a two-dimensional array with dimensions 3 by 3:

(check-type a1 (array * (3 3)))

Note that you can define your own types with DEFTYPE with arbitrary type predicates.

Use Lisp constructs which signal errors

For example ecase vs. case:

CL-USER 37 > (let ((code 10))
               (ecase code
                 (1 'fine)))

Error: 10 fell through ECASE expression.
Wanted one of (1).

ecase automatically signals an error, when no clause is matching.

The ASSERT macro allows us to check arbitrary assertions.

Common Lisp provides a built-in ASSERT macro.

(defun foo (n x)
  (assert (and (integerp n) (evenp n)) (n))
  (assert (floatp x) (x))
  (* (isqrt n) (sqrt x)))

Again, certain amount of runtime repair is available:

CL-USER 33 > (foo 2001 5.0)

Error: The assertion (AND (INTEGERP N) (EVENP N)) failed.
  1 (continue) Retry assertion with new value for N.
  2 (abort) Return to level 0.
  3 Return to top loop level 0.

Type :b for backtrace or :c <option number> to proceed.
Type :bug-form "<subject>" for a bug report template or :? for other options.

CL-USER 34 : 1 > :c 1
Enter a form to be evaluated:
2000
98.38699

Use CLOS for simple Design by Contract

(defclass bar ()
   ((n :type integer)
    (x :type float))) 

(defmethod setup-bar ((b bar) (n1 integer) (x1 float))
   (with-slots (n x) b
      (setf n n1 x x1))
   b))

Now we can write an extra method to check for example that n is larger than x:

(defmethod setup-bar :before ((b bar) (n1 integer) (x1 float))
   (assert (> n x) (n x)))

The :before method will always run before the primary method.

Add a Design by Contract system to CLOS

There are libraries for that. Quid Pro Quo is an example. There is also a simpler and older DBC implementation by Matthias Hölzl: Design by Contract.

Advanced error handling with the Condition System

Write condition types:

(define-condition mailer-incomplete-delivery-error
          (mailer-error)
  ((recipient-and-status-list :initarg :recipient-and-status-list
                  :reader mailer-error-recipient-and-status-list)))

Above is a new condition, based on the mailer-error condition. At runtime we can catch an SMTP response code and signal such a condition.

Write handlers and restarts to deal with errors. That's advanced. Extensive use of the condition system usually indicates better code.

Write and check tests

In many cases robust code needs a test suite. Common Lisp is no exception.

Let the user report errors

In many Common Lisp implementation one can get the error condition object, a backtrace and some environment data. Write those to an error log. Let the user report those. For example LispWorks has the :bug-form command in the debugger.

Unrest answered 5/12, 2015 at 17:12 Comment(4)
Just to add to the design by contract, quid-pro-quo adds to CLOS as runtime checksCambodia
A fantastic answer, better than I could have hoped for.Westerfield
I might add: the CHECK-TYPE assertions also assist the compiler in optimizing your code. Many CL compilers are “smart” enough to infer that, if x has passed (check-type x (integer 0 10)), that they can then optimize the subsequent code in that function for only integers from 0 to 10. Often, you win twice from these declarations.Mile
Wow, many times I read a Rainer answer I learn more than I had while reading a lisp book about that topic!Footstep

© 2022 - 2024 — McMap. All rights reserved.