Use format rather than str
I’d like to share a Clojure trick I’ve been using for years. It’s simple, it makes your code safer; I always highlight it when making reviews. The trick is: don’t use the str
function to concatenate strings. Instead, use format
. A couple of examples:
(let [username "Ivan"]
(str "The user `" username "` has been created."))
;; "The user `Ivan` has been created."
(let [user-id 5234]
(str "/files/uploads/" user-id))
;; "/files/uploads/5234"
The first point in favour of this approach is, str
turns nil into an empty string. Thus, when printing the final message, that’s unclear if a variable was an empty string or nil
:
(let [username nil]
(str "The user `" username "` has been created."))
;; "The user `` has been created."
The difference between these two is important. Say, an empty string means broken validation; a title of a book, a name of a person must not be blank. But if I got nil, most likey I missed the key in a map because of a namespace:
(def user
{:acme.user/id 5234
:acme.user/name "Ivan"})
(let [username (get user :id)]
...)
or the keyword/string case:
(def user
(parse-json "user.json"))
;; {"id" 5234 "name" "Ivan"}
(let [username (get user :name)]
...)
Now compare it to the format
function. The nil
value becomes "null"
when passed to format
:
(let [username nil]
(format "The user `%s` has been created." username))
;; "The user `null` has been created."
(let [user-id nil]
(format "/files/uploads/%s" user-id))
;; "/files/uploads/null"
If I had my way, I would produce not "null"
but "nil"
string from nil
, but that’s not so important.
The second point is much more serious. Nil
values are extremely dangerous when building file paths or URLs. Imagine you’re about to delete files of a person who’s terminating their account. Most likely you store files on disk like this:
files/<user-id>/avatars/...
files/<user-id>/attachments/...
files/<user-id>/uploads/...
Then you have a function that accepts the user-id
parameter, then builds the right path and does recursive deletion:
(defn drop-user-files [user-id]
(let [path
(str "files/" user-id)]
(rm-rf-recur path)))
If you pass nil
for user-id
, the path will be "files/"
. Running that code will erase all the files of all users which would be a disaster. But if you have used format, the path would have been "files/null"
, which would just have thrown an exception saying there is no such a directory.
One may say: add an assert clause for user-id right before you build a path. Like this:
(defn drop-user-files [user-id]
(assert user-id "User-id is empty!")
...)
;; or
(defn drop-user-files [user-id]
{:pre [user-id]}
...)
In practice, you easily forget doing this and recall when the data is lost. I don’t see any reason for skipping that minor fix — change str
to format
— to reduce chances of a disaster.
The same applies to S3 URLs. Although it’s a web-service, we all treat it as a file system. Composing S3 URLs reminds me of ordinary file paths. Again, if you’re about to drop user’s directory with uploads, be aware of the the same thing: str
+ nil
for user-id
produce a broken path:
(defn drop-s3-user-files [s3-client user-id]
(let [path
(str "files/" user-id)]
(s3.client/delete-files s3-client path)))
If you pass nil
into a function that recursively drops S3 files, the data is all gone.
Of course, such an issue can be held with special Java classes like Path
or URI
. But in fact, in Clojure we use them quite rarely. Most often we just concatenate plain strings as it’s enough for the task. It’s simpler and takes less code.
I recommend using str
for one purpose only — to coerce a non-string value to a string. For example:
(str 5) ;; => "5"
(str (random-uuid)) ;; => "154ac...b2642"
Briefly, it’s safe when the str
function accepts strictly one argument. When there are more than one, I feel worried.
I always keep in mind a real story about a guy who owned a small hosting company. He released the rm -rf $FOO/$BAR
command among the whole park. Too sad for him, both of the env vars were unset, nor special bash flags terminating a script were set as well. The command turned into rm -rf /
with known consequences. A couple of missing vars has ruined one’s business. By str
-ing strings, especially when building file paths, you may easily mess up the same way (but I wish you won’t).
Let’s recap:
str
turnsnil
into an empty string;- by reading such a message, you never know if a value was an empty string or nil;
- the difference between these two does matter;
- with that behaviour, it’s easy to build a weird file/S3 path and lost the data;
- instead,
format
turnsnil
into"null"
. This is much better than emptiness and solves the troubles mentioned above; - use
str
only to coerce a value to a string.
Safe coding!
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter