5 ms·
I have found, across several languages I've used, that types embedded into functions are generally a bad idea, and I think the general principle is that types g
by jerf 2y ago
I have found, across several languages I've used, that types embedded into functions are generally a bad idea, and I think the general principle is that types generally end up needing to be exposed to any code that will also test that code. So, for instance, it's fine to confine types to some particular module, as long as those types are internal-only, but confining them within functions generally becomes a bad idea.
I know the complaints many of you are gearing up to type, but my statement is a bit more complicated than you may have realized on first read; the key is the word "becoming", that I'm looking at the lifetime of the code and not a snapshot. The problem with embedding types into those smaller scopes is that while it may work at first... of course it does, it compiles, right?... they become an impediment to a number of operations over time. First, as I mentioned, testing is very likely at some point over the evolution of the module to want to either provide input or examine output, intermediate or otherwise, that exists in those types. Second, as the code grows, you want to be able to refactor things freely, and types embedded in functions form a barrier to refactoring because to refactor you'll have to do something to expose that type now to multiple functions. You do not want barriers to refactoring. Barriers to refactoring are a bigger expense over the long term than any small local gain from putting a type here instead of there, especially when anyone should have "Jump to Definition" readily available in this post-LSP era.
Considered over time, over the evolution of the code base, I've just never had any super-local types like this "survive". Every time I think I've found an exception, I've either had my test code or the desire to refactor force me to lift it to the module level. So I just start there now.
To the extent there is an exception, testing-only code may be. Testing-only code has very different constraints than production code anyhow. Even then, though, I still find that refactoring problem arises, and test code needs to be refactorable too.
On the plus side, while I label them "a bad idea", they are not a "bad idea" that destroys your code base or anything. On the grand scale of "bad ideas" in code, this is down in the "inconvenience" part of the scale. It is almost self-evidently not some sort of disaster and I am not claiming it is. You can always lift it out and move on. But it is one of the many little hygiene habits that add up that helps keep code fluid and refactoring always available to me at a minimum activation-energy cost, because that is really important.
(This applies specifically to types that you explicitly define. You can in Haskell, for instance, bash a new type together anywhere simply by creating a tuple (x, y). But this doesn't trigger what I'm talking about because any other bit of the code can bash the exact same type together simply by creating another tuple of the same type, and they'll unify just fine without having to share a type definition in common. No impediment of any kind is created by a new tuple type in that language.)
- chowells 2y agoSometimes I think I want local types in Haskell. I'm creating some new type and some instances for it solely for this function, why does it need global scope? Then I get around to remembering module scope is the actual important thing in Haskell, make sure that type isn't exported from the primary API modules, and get on with life.
- saghm 2y agoYeah, I think it's pretty rare to actually need a "local" type as opposed to just making it private to a module in Rust as well. The use case the article gives is one of the few where it I could see it being useful; if you're using a macro in the body of a function and want it to generate a type, there's not anywhere else you can define it (without an additional macro invocation outside the function, which often defeats the purpose of trying to wrap up all the boilerplate into one concise place).
- Joker_vD 2y agoOne of the somewhat useful (but still mind-boggling) uses of local types I've encountered was in Go, writing a custom unmarshaller: func (s *MyLovelyStruct) UnmarshalJSON(b []byte) error { err := json.Unmarshal(b, s) if err != nil { return err } return validateAndMassageMyLovelyStruct(s) } I want to re-use the default "dump JSON key/values into the struct's fields" logic, then add something on top of it. But as written, this method will blow up with stack overflow because json.Unmarshal(b, s) will call s.UnmarshalJSON(b), if it can. So what you can do is this: func (s *MyLovelyStruct) UnmarshalJSON(b []byte) error { type IncognitoStruct MyLovelyStruct tmp := IncognitoStruct{} err := json.Unmarshal(b, &tmp) if err != nil { return err } *s = MyLovelyStruct(tmp) return validateAndMassageMyLovelyStruct(s) } The IncognitoStruct, even if it has exact same fields as MyLovelyStruct (and is castable to it), does not have any of its methods, so json.Unmarshal(b, &tmp) does not recursively call this UnmarshalJSON() method. But even that uses type aliases, not the proper, data-holding, types themselves. I never found any motivation to use those; the package-local types are quite enough.
- 2y ago