Escape Hatches: Principles vs. Guarantees

Escape Hatches: Principles vs. Guarantees

Colin Jones
Colin Jones

October 15, 2019

Often, as well-meaning programming language enthusiasts, we might find ourselves encouraging newcomers with simplified descriptions of language features. There’s sometimes a tricky balance between explaining things in an approachable way and explaining them correctly and precisely. Let me give some examples of what I’m talking about, and then we can zoom out to think about the implications.

Ruby

Ruby, like many OO languages, has visibility methods that let you ensure nobody will depend on the code you’re using! This helps you know what’s safe to modify!

Sure enough, the basics work like we'd expect:

class ActivateUser
		def call(activation_code)
				user = fetch_user(activation_code)
				raise ArgumentError, "Activation code '#{activation_code}' is invalid" if !user
				user.update!(:activation_code => nil, :active => true)
				user
		end

		private
		def fetch_user(activation_code)
				User.where(:activation_code => activation_code).first
		end
end

class TestActivateUser < Minitest::Test
		def setup
				User.delete_all
				User.create(:email => "jen@example.com", :activation_code => "GOOD_CODE")
				@activator = ActivateUser.new
		end

		def test_a_bad_activation_code_cant_be_fetched
				assert_raises(ArgumentError) { @activator.call("BAD_CODE") }
		end

		def test_a_good_activation_code_updates_and_returns_the_user
				user = @activator.call("GOOD_CODE")
				assert_equal user, User.where(:email => user.email).first
				assert_equal "jen@example.com", user.email
				assert_nil user.activation_code
				assert_equal true, user.active
		end

		def test_a_good_activation_code_updates_the_user
				user = @activator.call("GOOD_CODE")
				assert_equal "jen@example.com", user.email
				assert_nil user.activation_code
				assert_equal true, user.active
		end

		def test_private_method_access_prevented
				assert_raises(NoMethodError, /private method `fetch_user` called for/) {
						@activator.fetch_user("GOOD_CODE")
				}
		end
end

But we can bypass private methods in at least a few ways:

def test_private_method_access_allowed_with_send
		user = @activator.send(:fetch_user, "GOOD_CODE")
		assert_equal "jen@example.com", user.email
end

def test_private_methods_can_be_made_public
		# We could send this message to `ActivateUser` instead, but then we'd
		# need `Minitest::Test.i_suck_and_my_tests_are_order_dependent!` to keep
		# our tests deterministic, and nobody wants that.
		@activator.singleton_class.send(:public, :fetch_user)

		user = @activator.fetch_user("GOOD_CODE")
		assert_equal "jen@example.com", user.email
end

Ah, Ruby. private is a principle, not a guarantee.

Clojure

Use Clojure - you don’t have to worry about anyone mutating your collections out from under you! Immutability by default!

Not so fast. This test suite passes too:

(test/deftest test-immutability
		(test/testing "the awesomeness of immutability"
				(let [xs [0 1 2 3 4 5 6 7 8 9]
										ys (map (fn [x] (* x x)) xs)]
						(test/is (= (range 10) xs))))

		(test/testing "the pleasing nature of... wait what??"
				(let [xs [0 1 2 3 4 5 6 7 8 9]]
						(do-something xs)
						(test/is (= (repeat 10 :boom) xs)))))

In Clojure, you can use Java interop to mutate PersistentVector. Similar things are possible for other persistent data structures.

(defn- replace-array [f arr]
		(dotimes [i (alength arr)]
				(let [current (aget arr i)]
						(aset arr i (f current)))))

(defn- replace-node [f root]
		(let [node-arr (->> root .array)
								first-node (first node-arr)]
				(if (and first-node (instance? clojure.lang.PersistentVector$Node first-node))
						(doseq [node (filter identity node-arr)]
								(replace-node f node))
						(replace-array f node-arr))))

(defn mutate-vector [f xs]
		(replace-node f (.root xs))
		(replace-array f (.tail xs)))

(defn do-something [xs]
		(mutate-vector (constantly :boom) xs))

Ah, Clojure. "Immutability" is a principle, not a guarantee.

Haskell

You should use Haskell, where the type system enforces immutability and even side effects! You can tell just by looking at the type signature!

You can probably sense where this is going: this isn’t really true either. unsafePerformIO can take you to some pretty gnarly places:


count :: String -> Int
-- definition redacted for now

data StringData = StringData {
		text :: String,
		reversed :: String,
		size :: Int
} deriving (Show)

stats :: String -> StringData
stats text = StringData {
		text = text,
		reversed = reverse text,
		size = count text
}

main :: IO ()
main = print (size (stats "Hello, World!"))

Since count returns an Int, we should see main printing an Int. But that's not what happens!

It prints:

"OHH NOOOOOO"
13

Despite the type signature clearly indicating purity, we’re able to do whatever impure side-effecting operations we want:

import System.IO.Unsafe (unsafePerformIO)

count :: String -> Int
count text =
		unsafePerformIO $ do
				print "OHH NOOOOOO"
				return $ length text

Ah, Haskell. Purity is a principle, not a guarantee.

Zooming out

If you're a developer and/or advocate for one of these technologies, I’m sure you have your counter-arguments ready about why the example in your preferred language isn’t such a big deal. It might surprise you, but I don’t disagree with the counterpoints—far from it!

My point is not that everything is on fire and nothing is sacred. Affordances to help developers do the right things can still be very useful, even when they’re not guarantees. You can break a law, but laws are still useful. And programming principles with escape hatches can be, too.

I don't want to litigate the necessity of these escape hatches for the language designers, because I don't think it's actually that important. Whatever the reasons, these facilities give us the power to do things that seem to conflict with key principles in these languages.

And when we overstate our opinions and frame principles as guarantees, to put it charitably we confuse people about what they can and should believe. We don’t have to give everyone all the details of edge cases like these. But some acknowledgement of where the lines exist, between principle and guarantee, can help us to avoid those misunderstandings and potential distrust.

Being precise with language can be a huge pain sometimes. Such a huge pain that I'm confident there are at least a handful of imprecise and even incorrect statements in this post. But by striving for precision as a principle, I think we're a lot more likely to build trust with the folks we're communicating with.

If you're interested in playing with these examples, check out the Github repo.