Option<fn(T)> over PhantomData<T>

Lately when I run into a case where I need PhantomData<T>, I've been preferring to use Option<fn(T)> instead.

I prefer it over the latter because,

  1. I don't need to import std::marker::PhantomData
  2. derive(Default) still works
  3. Using None feels more convenient than having to use PhantomData::default()

The only disadvantage I see, is that it's 8 bytes vs PhantomData<T>'s 0 bytes but in many cases this seems inconsequential.

Any thoughts or feedback on this?

Edit: From guidance below, Option<fn() -> T> is probably more preferred

2 Likes

Option<fn(T)> has the opposite lifetime variance, contravariant in T. It also has no bearing on auto traits since fn(..) implements all, whereas PhantomData<T> will act like T in that regard.

8 Likes

The PhantomData unit struct constructor is public, there's no need for ::default(). Just (import and) write PhantomData.

11 Likes

According to that link, would Option<fn() -> T> be covariant?

Could you explain this a bit more? For more context, the situations I run into this are when I'm doing something like this,

struct Foo<T> {
..
}

without needing an actual field for T. Would auto-traits apply in cases like this?

1 Like

Yep!

The auto traits are implemented for fn pointers regardless of the argument or return types. That means your Foo<T> will be Send, Sync, etc. even if T is not.

I can't answer whether that's appropriate for your type without knowing what you do with it.

3 Likes

Ah okay that makes sense.

Basically, I'm using it to apply generic constraints on impl Foo rather than on struct Foo, so that I can chain closures that use T and abstract the boilerplate that accesses T with Foo.

Here is an abbreviated example of how I'm using this,

struct Foo<T, const ASYNC: boo = false> { 
  ...
  _unused: Option<fn() -> T>
}

/// Impl for all T
impl<T, const ASYNC: bool> Foo<T, ASYNC> {
  fn result(self) -> Result<Self, Error> { ... }
}

/// Impl for T: Send + Sync
impl<T: Send + Sync> for Foo<T> 
{
  fn do(self, c: impl FnOnce(&T) -> Result<(), Error>) -> Self {
    // Some boilerplate that returns T
    ...
    self
  }

  fn enable_async(self) -> Foo<T, true> {
    ...
  }
}

/// Async Impl for T: Send + Sync
impl<T: Send + Sync> for Foo<T, true> 
{
  async fn do<F>(self, c: impl FnOnce(&T) -> F) -> Foo<T, true> 
  where
    F: Future<Output = Result<(), Error>>,
  {
    // Some boilerplate that returns T
    ...
    self
  }

  fn disable_async(self) -> Foo<T, false> {
    ...
  }
}

/// Does some stuff to get Foo<T>
fn get_foo<T: Send + Sync>() -> Foo<T> { ... }

async fn main() -> Result<(), Error> {
  get_foo::<SomeTypeThatFooCanGet>()
    .do(|_| { ... })
    .enable_async()
    .do(|_| async { ... }).await
    .result()?
    .disable_async()
    .do(|_| { .. })
    .result()
}

PhantomData has implication on drop checker. These two constructs have different semantic and are not interchangeable.

PhantomData's having implications for drop check (compared to using no PhantomData are mostly coming from bad or outdated documentation. If I recall correctly, some of the documentation was somewhat recently improved. The actual effects are generally non existing, unless you use the unstable may_dangle attribute. I believe some longer time ago, there used to be implications in other cases, too, which no longer apply.

Edit: Actually... maybe I've missed your point. Option<T> does affect drop check in a way PhantomData<T> doesn't, as far as I remember, so maybe that difference was your point.

Edit2: On second though: we need to be comparing Option<fn() -> T>, not Option<T>, with PhantomData<T>, and then there should be not really any difference for drop check, unless may_dangle is used.

2 Likes

There is no "best" pattern to use for PhantomData. Which pattern you use has impacts both on how auto-traits and (co-/contra)variance (and drop check in case of may_dangle) are affected.

Unfortunately, several places in the documentation miss to emphasize the part about auto-traits. :confused:

I always try to use a "speaking" pattern which resembles semantically what the PhantomData is about: Make the type act as if the given type (in angle brackets behind the PhantomData) would be contained in the struct. Even if this might not always be necessary (e.g. because I manually influence Sendness or Syncness, or because I know I didn't use may_dangle), I feel like this is least confusing and least prone to result in (bad) surprises.

The idea of PhantomData is to avoid extra memory being wasted. Replacing it with an Option can't fulfill the goals of PhantomData. Only use an Option<T> if you sometimes (decided at runtime) actually want to store a value there.

2 Likes

In regard to what you wrote here

I would say the corresponding type is:

struct Foo<T, const ASYNC: bool = false> {
    /* … */
    _unused: PhantomData<fn(fn(&T))>,
}

because you pass a closure, which takes a shared reference to T as argument.

Maybe in this particular case, it might be better to simplify it though, depending on your implementation and/or other methods. Maybe if Foo<T, const ASYNC> can/should actually be seen as "containing" a T, then PhantomData<T> might work just as fine.

(I assume PhantomData<fn(fn(&T))> is covariant over T, right? One fn makes it contra-variant and the other makes it co-variant again, I would say.)


For example in mmtkvdb::Db, I use this:

pub struct Db<K: ?Sized, V: ?Sized, C> {
    key: PhantomData<fn(&K) -> &K>,
    value: PhantomData<fn(&V) -> &V>,
    constraint: C,
    backend: ArcByAddr<DbBackend>,
}

Because I have methods that basically take a reference to K and/or V and return references to K and/or V.


Update: I just realized that the fn(&K) -> &K only makes sense because I operate with &Db (and not &mut Db) when writing keys/values. Thus I need invariance, which is what PhantomData<fn(&K) -> &K> does. However, if I would require a &mut Db for writing keys/values, then covariance would be just fine. Compare: An Option<T> is also covariant over T, even if it has methods like Option::insert, which take a T as argument (but work on &mut self). So the rule of trying to resemble the interface in the PhantomData might be more tricky than I anticipated.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.