Live data from Hacker News

Simple Is Not Small

jyn.dev

91–98 of 98 posts

Re: Simple Is Not Small

#91
post #87

Earlier quoted context omitted.

I think I struggle to assemble a coherent notion of what it means to (conceptually) decouple a value from its type. You can completely forget the type of a value and treat it as opaque bytes, but then there are no valid operations left on the value. Even moving it around or discarding it may be invalid if it's pointed to elsewhence. The only thing you can meaningfully do is try to recover its (static or dynamic) type…

Types are used to both define how data is represented in memory and to restrict which values are allowed. For instance, an enum might be internally represented as a byte, but the compiler further restricts the permitted values to those the enum represents, and limits what operations are available (e.g. we can multiple a u8 by a u8, but not an enum by an enum). In this sense, most statically-typed languages conflate h…

In fact the opposite is true: raw values have no valid operations and types allow you to add operations that make sense for those values. It's an unfortunate historical accident, which we're slowly getting over, that we conflate values with data (in the sense of ‘plain old data’, i.e. values that support a special hardware-supported kind of copying and moving, et cetera) and data with numbers, and then think we have to use types to restrict it from being treated as numbers and get back to smaller sets of values.

> In this sense, most statically-typed languages conflate how data is structured with how it is restricted.

Most statically-typed languages are actually very loose about how values are structured at runtime, leaving it mostly up to the implementation (e.g. see C++ padding and field reordering, or Haskell's autoboxing, which makes approximately no guarantees about what's behind the pointer — usually some graph-rewriting metadata). Where the conflation does exist is that a lot of systems languages allow you to write and typecheck code that assumes something about the language's representation of the type's values (e.g. that you can take the address of a field of a struct and later dereference it), though you can usually opt out of that with PIMPL or a trait object or something. But guaranteeing (and allowing the programmer to rely on the guarantee) that every value's representation also carries a bunch of additional runtime information is a much stronger version of that coupling.

> This can be useful when dealing with data that is in some sense invalid. You might receive data that's outside expected bounds or even of a different type, and it might make sense to handle it in some fashion.

The very fact that you can handle that data at all means that the value carries additional type information that allows you to do so. It's only ‘decoupled’ from the type in the sense that you didn't have to write it there, because it's automatically coupled to every value representable in the language regardless of what type you give it.

> This is a common necessity in pharmaceutical trials, for example.

I'll have to do some guesswork here, but I imagine when people make arguments like this they are significantly imagining a situation in which, say, all the values are expected to be in the range [5, 100] and some befuddled experimenter or piece of machinery gives you the value 2. A-ha, you say: I know sometimes the equipment undermeasures near the bottom of its range, so I'll clamp this value to 5!

This isn't really a type error. The fact you know you can safely do that means that the data is really typed in a different range than you said — but it's still typed. The conceptual type (even if you never write it down) is inherent in the very fact that you can somehow handle it: you know what to do with values down to 2 so the real type of supported inputs is at least [2, 100] (with some special semantics for the low values beyond that of being numbers).

A real type error looks like: you're expecting values in [5, 100] and then one of the values actually turns out to be the concept of intellectual honesty. That type doesn't support ~any of the same operations as the numbers you were expecting, even with the extended domain that more accurately reflects the set of values you can really accept. Even discarding it might have disastrous results for your experiment! In fact, the concept of intellectual honesty doesn't even have a good discriminator: while I know that's what you got because I'm the rascal who snuck it in there, you have no idea what it is, and no way of finding out. Most likely you're going to try to compare it to 5 to see if it needs to be clamped, with unpleasant consequences for us all.

Re: Simple Is Not Small

#92
post #91

Earlier quoted context omitted.

Types are used to both define how data is represented in memory and to restrict which values are allowed. For instance, an enum might be internally represented as a byte, but the compiler further restricts the permitted values to those the enum represents, and limits what operations are available (e.g. we can multiple a u8 by a u8, but not an enum by an enum). In this sense, most statically-typed languages conflate h…

In fact the opposite is true: raw values have no valid operations and types allow you to add operations that make sense for those values. It's an unfortunate historical accident, which we're slowly getting over, that we conflate values with data (in the sense of ‘plain old data’, i.e. values that support a special hardware-supported kind of copying and moving, et cetera) and data with numbers, and then think we have…

> In fact the opposite is true: raw values have no valid operations and types allow you to add operations that make sense for those values.

I think you're using 'value' to mean something slightly different to the way I meant it. So to get us onto the same page, by 'value', I mean something independent of how its stored; that is, the number 1 is a value, whether it's stored as as 00000001 or 0000000000000001.

A type limits both which values it is possible to represent (i.e. a u8 limits us to representing the integers 0 to 255), and also determines how it is encoded in memory (in this case 8 bits).

> Most statically-typed languages are actually very loose about how values are structured at runtime

Yes, but ultimately the compiler needs to be able to map a sequence of bits to the value it represents, even if there's not a strict one-to-one mapping.

> I'll have to do some guesswork here, but I imagine when people make arguments like this they are significantly imagining a situation in which, say, all the values are expected to be in the range [5, 100] and some befuddled experimenter or piece of machinery gives you the value 2

That's a common assumption, but the reality can be much more messy. It might be that we expect an integer between 5 and 100, but receive a string of UTF-8 characters instead.

For example, suppose you want to record a patient's date of birth. In Clojure, we might represent that as a map that connects a :patient/birthdate key with an encoded date object:

    {:patient/birthdate #date "1972-04-08"}
But what if the patient doesn't know their exact birthdate? Perhaps they immigrated when they were a young child from a less developed country and don't know the exact year they were born in. However, they tell the doctor that they do know they were born before 1980, because that's when they first arrived in the country.

In this case, the doctor might record the data as:

    {:patient/birthdate "before 1980"}
Even though the data doesn't match the type we expect (a date), it's important that it still be recorded as it could affect the medicine that the patient is given. These sorts of messy entries are not uncommon in areas where it's more important to accurately record the data than precisely type it.

Re: Simple Is Not Small

#93
post #91

Earlier quoted context omitted.

In fact the opposite is true: raw values have no valid operations and types allow you to add operations that make sense for those values. It's an unfortunate historical accident, which we're slowly getting over, that we conflate values with data (in the sense of ‘plain old data’, i.e. values that support a special hardware-supported kind of copying and moving, et cetera) and data with numbers, and then think we have…

> In fact the opposite is true: raw values have no valid operations and types allow you to add operations that make sense for those values. I think you're using 'value' to mean something slightly different to the way I meant it. So to get us onto the same page, by 'value', I mean something independent of how its stored; that is, the number 1 is a value, whether it's stored as as 00000001 or 0000000000000001. A type l…

> I think you're using 'value' to mean something slightly different to the way I meant it. So to get us onto the same page, by 'value', I mean something independent of how its stored; that is, the number 1 is a value, whether it's stored as as 00000001 or 0000000000000001.

I'm using it in its most general sense: as an object of discourse in a programming language, independent of representation or semantics. 1 is a value in most languages, to be sure, but it's also specific type of value, viz. a number: you can do number things to it, like add it or divide it, or check it for equality with 2.

> A type limits both which values it is possible to represent (i.e. a u8 limits us to representing the integers 0 to 255), and also determines how it is encoded in memory (in this case 8 bits).

To reiterate: a type doesn't limit the values but specifies the values (or rather, more generally, the meaningful operations on a value of that type). Values are not numbers by default; only by being typed as a number does a value take on number semantics. Without the knowledge that a value is a number it is meaningless to treat it as a number.

> Yes, but ultimately the compiler needs to be able to map a sequence of bits to the value it represents

Sure; in any language implementation you have to represent the values somehow, nobody could disagree. My point is that the type doesn't (necessarily) specify that representation in any language I can think of.

> That's a common assumption, but the reality can be much more messy. It might be that we expect an integer between 5 and 100, but receive a string of UTF-8 characters instead.

Sure: that's not fundamentally different from the first example I gave. The real type of `:patient/birthdate` there is just the (discriminated) union of the date type and the string type. It still has a type, and if it didn't you wouldn't be able to process it (definitionally, because a type tells you what kind of processing makes sense for the value). And the string values aren't ‘outside’ the type: even if you choose to write the wrong type down in your Clojure, the fact that you also process strings means that you know the real type (and you embed that knowledge into the code).

Re: Simple Is Not Small

#94
post #93

Earlier quoted context omitted.

> In fact the opposite is true: raw values have no valid operations and types allow you to add operations that make sense for those values. I think you're using 'value' to mean something slightly different to the way I meant it. So to get us onto the same page, by 'value', I mean something independent of how its stored; that is, the number 1 is a value, whether it's stored as as 00000001 or 0000000000000001. A type l…

> I think you're using 'value' to mean something slightly different to the way I meant it. So to get us onto the same page, by 'value', I mean something independent of how its stored; that is, the number 1 is a value, whether it's stored as as 00000001 or 0000000000000001. I'm using it in its most general sense: as an object of discourse in a programming language, independent of representation or semantics. 1 is a va…

> To reiterate: a type doesn't limit the values but specifies the values (or rather, more generally, the meaningful operations on a value of that type).

But a value can have more than one possible type. The number 1 could come from an unsigned byte, or a signed long, for example. These are different types that support the same numerical operations, but differ in cardinality. So we can't say that a type's only purpose is to specify the meaningful operations on a value, as we might have two types that are identical in that regard.

> The real type of `:patient/birthdate` there is just the (discriminated) union of the date type and the string type.

Yes, in this particular instance that would be the case, but that's not necessarily something you know ahead of time. The point is that you may not have anticipated that not everyone would know their date of birth, and the data you receive is invalid according to your earlier assumptions.

In Clojure this results in a more graceful failure condition. Functions that don't require the date of birth will continue to work with no change required. If I want the average white blood cell count of a patient, I don't care what the date of birth is, and therefore the output for that particular operation isn't affected.

Re: Simple Is Not Small

#95
post #93

Earlier quoted context omitted.

> I think you're using 'value' to mean something slightly different to the way I meant it. So to get us onto the same page, by 'value', I mean something independent of how its stored; that is, the number 1 is a value, whether it's stored as as 00000001 or 0000000000000001. I'm using it in its most general sense: as an object of discourse in a programming language, independent of representation or semantics. 1 is a va…

> To reiterate: a type doesn't limit the values but specifies the values (or rather, more generally, the meaningful operations on a value of that type). But a value can have more than one possible type. The number 1 could come from an unsigned byte, or a signed long, for example. These are different types that support the same numerical operations, but differ in cardinality. So we can't say that a type's only purpose…

> But a value can have more than one possible type. The number 1 could come from an unsigned byte, or a signed long, for example.

Here we disagree. Unsigned 8-bit integers† and signed 64-bit integers, while both conveniently notated with Arabic numerals, are actually different values that support different operations, for example negation. They share quite a few similarities in how their operations interact with one another, for example each (assuming wrapping) is a monoid with 0 and +, but the semantics of the actual operations differs if looked at more closely. Mathematics agrees: the element ‘1’ of N/2⁸ and the element ‘1’ of Z/2⁶⁴ are not the same thing (their encodings coincide sometimes, but it's poor form to make assumptions about it).

† Bytes are data but not numbers, and so support only data operations like duplication and discarding, not number operations like adding and multiplication: as an artefact of representation you can usually ask the hardware or programming language to manipulate them as if they were numbers, but the result remains meaningless.

> The point is that you may not have anticipated that not everyone would know their date of birth, and the data you receive is invalid according to your earlier assumptions.

But that's exactly what I'm saying: you must have made some assumptions about that missing data, otherwise there is no safe thing you can do to it (including discarding it, which is a popular choice). This works in Clojure only because Clojure couples some semantics (data semantics plus operations on runtime type information) into every value, i.e. it restricts what values are even representable in the language in order to ensure that this function will always be safe to write.

In more strictly typed languages you can still talk about values that support these behaviours, but you are required to be explicit about it, because there are some values that can be represented that don't support these operations.

> Functions that don't require the date of birth will continue to work with no change required.

Any function that takes the date of birth ’requires’ the date of birth (or more generally a possibly-empty set of ‘leftover’ values). The only thing that differs is what's required from it: some functions might require that it be a date while other functions only require that it be data, for example. The universally imposed limitation that all values must be coupled to data semantics and runtime type information is convenient for ergonomics if you write a lot of these functions (since you don't have to remember to write that assumption down), but it's important to remember that it is a coupling — the resulting values are more complex than values without those things bundled on, and the trade-off is that you can no longer talk about values for which they don't hold.

Re: Simple Is Not Small

#96
post #95

Earlier quoted context omitted.

> To reiterate: a type doesn't limit the values but specifies the values (or rather, more generally, the meaningful operations on a value of that type). But a value can have more than one possible type. The number 1 could come from an unsigned byte, or a signed long, for example. These are different types that support the same numerical operations, but differ in cardinality. So we can't say that a type's only purpose…

> But a value can have more than one possible type. The number 1 could come from an unsigned byte, or a signed long, for example. Here we disagree. Unsigned 8-bit integers† and signed 64-bit integers, while both conveniently notated with Arabic numerals, are actually different values that support different operations, for example negation. They share quite a few similarities in how their operations interact with one…

> Here we disagree. Unsigned 8-bit integers† and signed 64-bit integers, while both conveniently notated with Arabic numerals, are actually different values that support different operations, for example negation.

What about a 32 bit unsigned integer and a 64 bit unsigned integer? Are they still separate values?

You appear to be saying that the values of a type cannot be a subset of another type; that is, there is no '1', only a '1' that is an integer, a '1' that is a short, and so forth, and every '1' is distinct.

Fine, that's a possible way of looking at it, but why is that more valid or consistent than a model that allows subtypes? That, for example, the value '1' could be both a Number, an Integer, and a NaturalNumber?

Further, what's the practical difference between a type defined as consisting of the numbers [1 2 3], and an integer that's restricted to those values?

> This works in Clojure only because Clojure couples some semantics (data semantics plus operations on runtime type information) into every value, i.e. it restricts what values are even representable in the language in order to ensure that this function will always be safe to write.

Even if we view a Clojure value as a coupling between type and data, that's only a coupling between two things, and it ensures we can avoid further coupling caused by large record types. On net, we reduce the amount of coupling a statically typed language that uses closed record types would require.

For instance:

    (defn average-wbc-count [{:patient/keys [wbc-counts]}]
      (/ (apply + wbc-counts) (count wbc-counts)))
This function is coupled to only one key/value pair. Any other information in the map is irrelevant, which is why an invalid :patient/birthdate doesn't cause the function to fail. There's no coupling between :patient/birthdate and average-wbc-count.

Conversely:

    fn average_wbc_count(patient: &Patient) -> f64 {
      patient.wbc_counts.iter().sum::() / patient.wbc_counts.len() as f64
    }
This function requires patient to be a Patient struct, and therefore the function is implicitly coupled to every field in the struct, regardless of whether that field is ever actually used. I need to ensure that a patient has some birthdate that's an anticipated type (even if that's an error type) before I can call the function.

Re: Simple Is Not Small

#97
post #95

Earlier quoted context omitted.

> But a value can have more than one possible type. The number 1 could come from an unsigned byte, or a signed long, for example. Here we disagree. Unsigned 8-bit integers† and signed 64-bit integers, while both conveniently notated with Arabic numerals, are actually different values that support different operations, for example negation. They share quite a few similarities in how their operations interact with one…

> Here we disagree. Unsigned 8-bit integers† and signed 64-bit integers, while both conveniently notated with Arabic numerals, are actually different values that support different operations, for example negation. What about a 32 bit unsigned integer and a 64 bit unsigned integer? Are they still separate values? You appear to be saying that the values of a type cannot be a subset of another type; that is, there is no…

> You appear to be saying that the values of a type cannot be a subset of another type; that is, there is no '1', only a '1' that is an integer, a '1' that is a short, and so forth, and every '1' is distinct.

That's true, and you can prove it simply by observing that they react differently to ‘the same’ operations, e.g. (2¹⁶ - 1) + 1 has a different value (that responds differently to tests like `≥ 0`) depending on which type we're talking about. There's an injection into the larger type (a type coercion) that is very well-behaved, but it's not an identity map — it changes the operations on the value.

> Further, what's the practical difference between a type defined as consisting of the numbers [1 2 3], and an integer that's restricted to those values?

Nothing (ish: you have to be careful to describe what happens to all the integer operations when restricted to your type) — that's a totally valid way to define a type. But it's not the only way to define a type, because the world of types is much bigger than the world of restricted sets of integers.

> I need to ensure that a patient has some birthdate that's an anticipated type

No, you just have to be explicit about the possibility of a birthdate (or other fields) being of a wide type, e.g.

    struct Patient {
      wbc_count: Vec,
      birth_date: Box,
      other_fields: HashMap>,
    }

Clojure just attaches that by default to every value.

Re: Simple Is Not Small

#98
post #97

Earlier quoted context omitted.

> Here we disagree. Unsigned 8-bit integers† and signed 64-bit integers, while both conveniently notated with Arabic numerals, are actually different values that support different operations, for example negation. What about a 32 bit unsigned integer and a 64 bit unsigned integer? Are they still separate values? You appear to be saying that the values of a type cannot be a subset of another type; that is, there is no…

> You appear to be saying that the values of a type cannot be a subset of another type; that is, there is no '1', only a '1' that is an integer, a '1' that is a short, and so forth, and every '1' is distinct. That's true, and you can prove it simply by observing that they react differently to ‘the same’ operations, e.g. (2¹⁶ - 1) + 1 has a different value (that responds differently to tests like `≥ 0`) depending on w…

> Nothing (ish: you have to be careful to describe what happens to all the integer operations when restricted to your type) — that's a totally valid way to define a type.

Then at which point does a type become equivalent to a restriction that could be decoupled from the underlying type by choosing a broader type?

> No, you just have to be explicit about the possibility of a birthdate (or other fields) being of a wide type

Yes, you could recreate Clojure's semantics in Rust. More accurately, I'd say you'd be looking at defining it like so:

    struct WbcCount {
      wbc_count: Vec
    }

    struct BirthDate {
      birth_date: Box
    }
Since we want to be able to reason about these keypairs individually. You might have a structure that has a WbcCount but not a BirthDate, or one with a BirthDate but not a WbcCount, or one with neither.

We'd then be faced with the challenge of creating a type that could contain an arbitrary number of unique structs, and to be able to pull a struct out of that set by its type. Realistically we'd probably just use a HashMap at that point and discard all static typing.

Alternatively, we could create a mega struct that contains every possible field we could want to use, whether or not we know they are related, and use this data structure to represent all structured data in the application. The "if everything is coupled, nothing is" approach.

But both of those options are difficult or unidiomatic to write.

Languages like Rust, Java, etc. encourage coupling of data because it's more convenient and space efficient to group data together in records/structs. In Clojure, there's no need to do so; we can couple only when necessary. If we don't need to know the birthdate to calculate the white blood cell count, then we can exclude that field from the input type checking. Most statically typed languages find this difficult, with TypeScript being one of the few exceptions in this regard.

Post reply on HN