മെഷീൻ ലേണിംഗ് ഫണ്ടമെന്റൽസ് 2.5: മാക്രോസ് ഉപയോഗിച്ച് സിയിലെ ജനറിക് പെരുമാറ്റംDraft

സി പോലെയുള്ള ഒരു ലോ-ലെവൽ ഭാഷ ഉപയോഗിച്ച് ലഭിക്കുന്ന ധാരണയുടെ ട്രേഡോഫ് എന്നത് സവിശേഷതകളുടെ അഭാവമാണ്. പ്രദർശനത്തിനായി കോഡ് എഴുതുമ്പോൾ, ഇത് യഥാർത്ഥത്തിൽ ഒരു പ്രശ്നമല്ല. എന്നാൽ നിങ്ങൾ ഒരു യഥാർത്ഥ ആപ്ലിക്കേഷൻ എഴുതേണ്ടിവരുമ്പോൾ, അത് ഒരു പ്രശ്നമാണ്.

സിയിൽ നിന്ന് കാണപ്പെടുന്ന ശ്രദ്ധേയമായ സവിശേഷതകളിൽ ഒന്ന് കംപൈൽ-ടൈം ജനറിക്സ് ആണ്.

കംപൈൽ ടൈം ജനറിക്സ് എന്നത് ഇനിപ്പറയുന്ന നിയന്ത്രണങ്ങളെ ആശ്രയിച്ചുള്ള ഒരു തരം കോഡ് ഡ്യൂപ്ലിക്കേഷൻ ആണ്:

  1. ഒരേ ട്രെയ്റ്റ് നടപ്പിലാക്കുന്ന ഒന്നിലധികം ക്ലാസുകൾ നിലവിലുണ്ട്
  2. നടപ്പിലാക്കിയ ട്രെയ്റ്റ് വഴി മാത്രം ആ ക്ലാസുകളെ ആശ്രയിക്കുന്ന കോഡ് നിലവിലുണ്ട്

ഈ ലേഖനത്തിൽ, എല്ലാ എലിമെന്റ്-വൈസ് മാട്രിക്സ് ഓപ്പറേഷനുകളെയും ആശ്രയിക്കുന്ന ഒരു ഓപ്പറേഷൻ ട്രെയ്റ്റ് സംബന്ധിച്ച് സിയിൽ ജനറിക്സ് പുനരാവിഷ്കരിക്കുന്നതിനുള്ള ഒരു മാർഗം ഞാൻ നിങ്ങൾക്ക് കാണിക്കാൻ ആഗ്രഹിക്കുന്നു. സിയിലെ കോഡ് ഡ്യൂപ്ലിക്കേഷന്റെ ഏക ബിൽറ്റ്-ഇൻ രീതി ഉപയോഗിച്ചാണ് ഞങ്ങൾ ഇത് ചെയ്യാൻ പോകുന്നത്: മാക്രോസ്.

സി മാക്രോകൾ എന്താണ്?

സി മാക്രോകൾ #define ഡയറക്ടീവ് ഉപയോഗിച്ചാണ് സൃഷ്ടിക്കുന്നത്. ഇവ ഏറ്റവും ലളിതമായി ഇങ്ങനെ ഉപയോഗിക്കാം:

#define AN_IMPORTANT_NUMBER 42

ഇത് AN_IMPORTANT_NUMBER എന്നതിന്റെ എല്ലാ സംഭവങ്ങളും 42 എന്ന അക്ഷരസഞ്ചയം ഉപയോഗിച്ച് മാറ്റിസ്ഥാപിക്കുന്നു. നിങ്ങൾ ചിന്തിക്കുന്നുണ്ടാകും: ഇത് ഈ ഗ്ലോബൽ നിർവചനത്തിൽ നിന്ന് എങ്ങനെ വ്യത്യസ്തമാണ്?

const int AN_IMPORTANT_NUMBER = 42;
int main() { ... }

പ്രായോഗികമായി, വളരെ വ്യത്യാസമില്ല. എന്നാൽ അത് വളരെ ലളിതമായതിനാലാണ്. നമ്മൾ ഒരു മാക്രോ ഫംഗ്ഷൻ എഴുതിയാൽ എന്തായിരിക്കും?

#define SQUARED_MACRO(x) x*x
int square_function(int x) { return x*x; }

ഇവ രണ്ടും തമ്മിലുള്ള വ്യത്യാസം എന്താണ്? മാക്രോകൾ കുറച്ച് കൂടുതൽ സങ്കീർണ്ണമായ ഫൈൻഡ്-ആൻഡ്-റീപ്ലേസ് ആയി കരുതുമ്പോൾ ഇത് വ്യക്തമാകും. ഇത് സി പ്രീപ്രോസസർ ഉപയോഗിച്ച് കണക്കാക്കുന്നു, കൂടാതെ കംപൈൽ സമയത്ത് ഒരു കോഡ് മറ്റൊരു കോഡ് ഉപയോഗിച്ച് മാറ്റിസ്ഥാപിക്കുന്നു. ഉദാഹരണത്തിന്:

int x = SQUARED_MACRO(2);

ഇത് ഇങ്ങനെ വികസിപ്പിക്കുന്നു:

int x = 2*2;

ഫംഗ്ഷൻ ഒരു കോൾ ഇൻസ്ട്രക്ഷനല്ലാതെ മറ്റൊന്നിലേക്കും വികസിപ്പിക്കുന്നില്ല. മാക്രോയ്ക്ക് രണ്ട് ഗുണങ്ങളുണ്ട്. ഒന്നാമതായി, ഇതിന് ഒരു ഫംഗ്ഷൻ കോളിന്റെ ഓവർഹെഡ് ഇല്ല. രണ്ടാമതായി, ഇത് ടൈപ്പ് ചെയ്തിട്ടില്ല, അതിനാൽ SQUARED_MACRO ഗുണനം പിന്തുണയ്ക്കുന്ന ഏത് തരത്തിലും ഉപയോഗിക്കാം.

ഈ ഗുണങ്ങൾ നൽകുന്ന മാക്രോകളുടെ സ്വഭാവം തന്നെ നിരവധി അപകടകരമായ പ്രശ്നങ്ങൾ സൃഷ്ടിക്കുന്നു. SQUARED_MACRO ചില രസകരമായ കേസുകളിൽ എന്താണ് വികസിപ്പിക്കുന്നതെന്ന് നോക്കാം.

SQUARED_MACRO(2 + 3)
2 + 3*2 + 3

നമുക്ക് 25 വേണം, പക്ഷേ 11 ലഭിച്ചു. നല്ലതല്ല. എന്നാൽ ഇത് എളുപ്പത്തിൽ പരിഹരിക്കാം.

#define SQUARED_MACRO (x)*(x)

മറ്റൊരു ഉദാഹരണം:

int x = 1, sum = 0;
while (x < 10) { sum += SQUARED_MACRO(x++); }

ഇത് ഇങ്ങനെ വികസിപ്പിക്കുന്നു:

int x = 1, sum = 0;
while (x < 10) { sum += (x++)*(x++); }

x ഒരിക്കലല്ല, രണ്ടുതവണ ഇൻക്രിമെന്റ് ചെയ്യുന്നതായി നാം കാണുന്നു! അതിനുപരി, ഇത് x*(x+1) കണക്കാക്കുന്നു, x*x അല്ല.

അതിനാൽ, ഈ ലളിതമായ മാക്രോ ചില ഇൻപുട്ടുകൾക്ക് പൂർണ്ണമായും അസംബന്ധമായ കാര്യങ്ങൾ ഔട്ട്പുട്ട് ചെയ്യുന്നുവെന്ന് നാം കാണുന്നു, അത് കംപൈലർ സാധുവായതായി കണക്കാക്കുന്നു. മാക്രോകൾ കോഡ് ഡ്യൂപ്ലിക്കേറ്റ് ചെയ്യുന്നത് ഈ അപകടകരമായ പ്രവർത്തനങ്ങൾക്ക് കാരണമാകുന്നുണ്ടെങ്കിലും, ഇത് നമ്മെ കൂടുതൽ കാര്യക്ഷമമാക്കുന്നു.

ലൈബ്രറിയുടെ ലക്ഷ്യങ്ങൾ

ലൈബ്രറി മിനിമലിസ്റ്റ് (ഹെഡർ മാത്രം) ആകണമെന്ന് ഞാൻ ആഗ്രഹിക്കുന്നു, പക്ഷേ പൂർണ്ണമായതും. രണ്ട് മെട്രിക്സുകൾ , എന്നിവയ്ക്കിടയിലുള്ള പലതരം ഓപ്പറേഷനുകൾക്കായി ഞങ്ങൾ ഫംഗ്ഷനുകൾ എഴുതും.

ആവശ്യമായ ഫംഗ്ഷനുകളെ ഞാൻ താഴെ പറയുന്നവയായി തരംതിരിച്ചിട്ടുണ്ട്.

  • അലോക്കേഷൻ തരം: ഫലം ഒരു ബഫറിൽ ഇടണോ , അല്ലെങ്കിൽ ആദ്യ ആർഗ്യുമെന്റിലേക്ക് എഴുതണോ ?
  • ലൂപ്പ് തരം: ഡോട്ട് പ്രോഡക്റ്റ് തരം ലൂപ്പ് ആണോ, അല്ലെങ്കിൽ എലമെന്റ്വൈസ് ഓപ്പറേഷൻ ആണോ? രണ്ടാമത്തെ ആർഗ്യുമെന്റ് ട്രാൻസ്പോസ് ചെയ്യണോ ?
  • എലമെന്റ്വൈസ് ഓപ്പറേഷൻ തരം: ഗുണിക്കുകയാണോ? കൂട്ടുകയാണോ? കുറയ്ക്കുകയാണോ?

എല്ലാ ഫംഗ്ഷനുകളും (ഡോട്ട് ഒഴികെ) ഓപ്പറേഷൻ എന്നതിനെ സാധാരണയായി പിന്തുണയ്ക്കുന്നുവെന്ന് ശ്രദ്ധിക്കുക.

ബോയിലർപ്ലേറ്റ്

ആരംഭിക്കുന്നതിന് മുമ്പ്, നമ്മുടെ മാട്രിക്സ് തരം നിർവചിക്കേണ്ടതുണ്ട്. ഞാൻ mfloat എന്നൊരു തരവും നിർവചിക്കാൻ പോകുന്നു, അത് പിന്നീട് ഏതെങ്കിലും ഫ്ലോട്ട് തരത്തിന് പകരം വയ്ക്കാം.

#define DEBUG 1

typedef double mfloat;

typedef struct {
  mfloat *buf;
  int rows;
  int cols;
} matrix;

ഘടകങ്ങൾ buf-ൽ റോ-മേജർ ക്രമത്തിൽ സംഭരിച്ചിരിക്കുന്നുവെന്ന് ഞങ്ങൾ അനുമാനിക്കുന്നു. ഇപ്പോൾ, ലൈബ്രറിയുടെ ബാക്കി ഭാഗങ്ങളിൽ ഉപയോഗിക്കാൻ ബൗണ്ട്സ്-ചെക്കിംഗ് ഉള്ള ചില അടിസ്ഥാന ഗെറ്റർ-സെറ്റർ ഫംഗ്ഷനുകൾ ആവശ്യമാണ്.

static inline mfloat matrix_get(matrix m, int row, int col) {
  if (DEBUG)
    if (!(row >= 0 && col >= 0 && row < m.rows && col < m.cols)) {
      fprintf(
          stderr,
          "matrix_get: സീമകൾക്ക് പുറത്തുള്ള സൂചിക (%d, %d) മാട്രിക്സ് "
          "വലിപ്പത്തിന് (%d, %d)\n",
          row, col, m.rows, m.cols);
      exit(1);
    }

  return m.buf[row * m.cols + col];
}

static inline void matrix_set(matrix m, int row, int col,
                              mfloat val) {
  if (DEBUG)
    if (!(row >= 0 && col >= 0 && row < m.rows && col < m.cols)) {
      fprintf(
          stderr,
          "matrix_set: സീമകൾക്ക് പുറത്തുള്ള സൂചിക (%d, %d) മാട്രിക്സ് "
          "വലിപ്പത്തിന് (%d, %d)\n",
          row, col, m.rows, m.cols);
      exit(1);
    }

  m.buf[row * m.cols + col] = val;
}

മാട്രിക്സ് കൺസോളിൽ പ്രിന്റ് ചെയ്യാനുള്ള ഒരു മാർഗവും ആവശ്യമാണ്

static inline void matrix_print(matrix m) {
  for (int i = 0; i < m.rows; i++) {
    printf("[ ");
    for (int j = 0; j < m.cols; j++) {
      // ശാസ്ത്രീയ നൊട്ടേഷൻ, 4 ദശാംശ സ്ഥാനങ്ങളിലേക്ക് റൗണ്ട് ചെയ്തു
      printf("%.4e", matrix_get(m, i, j));
      printf(" ");
    }
    printf("]\n");
  }
  printf("\n");
}

ഹീപ്പിൽ നിന്നും ഹീപ്പിലേക്ക് മാട്രിക്സുകൾ അലോക്കേറ്റ് ചെയ്യാനും ഫ്രീ ചെയ്യാനുമുള്ള ഒരു മാർഗവും ആവശ്യമാണ്

matrix matrix_new(int rows, int cols) {
  double *buf = calloc(rows * cols, sizeof(double));
  if (buf == NULL) {
    printf("matrix_new: calloc പരാജയപ്പെട്ടു.");
    exit(1);
  }

  return (matrix){
      .buf = buf,
      .rows = rows,
      .cols = cols,
  };
}

ഡോട്ട് പ്രൊഡക്ട്

നമുക്ക് ഡോട്ട് പ്രൊഡക്ട് ഉപയോഗിച്ച് ആരംഭിക്കാം. ഞാൻ ഒരു ബഫർ-അലോക്കേറ്റ് പതിപ്പ് മാത്രമേ എഴുതുന്നുള്ളൂ, കാരണം ഔട്ട്പുട്ട് ആദ്യത്തെ ആർഗ്യുമെന്റിലേക്ക് എഴുതാൻ സാധിക്കുന്നത് രണ്ട് മെട്രിക്സുകളും സ്ക്വയർ ആയിരിക്കുമ്പോൾ മാത്രമാണ്, അത് നമുക്ക് അനുമാനിക്കാൻ കഴിയില്ല.

static inline void matrix_dot(matrix out, const matrix m1,
                              const matrix m2) {
  if (DEBUG)
    if (m1.cols != m2.rows) {
      printf(
          "matrix dot: dimension error (%d, %d) not compat w/ "
          "(%d, %d)\n",
          m1.rows, m1.cols, m2.rows, m2.cols);
      exit(1);
    }
  for (int row = 0; row < m1.rows; row++) {
    for (int col = 0; col < m2.cols; col++) {
      double sum = 0.0;
      for (int k = 0; k < m1.cols; k++) {
        double x1 = matrix_get(m1, row, k);
        double x2 = matrix_get(m2, k, col);
        sum += x1 * x2;
      }
      matrix_set(out, row, col, sum);
    }
  }
}

എലമെന്റ് വൈസ് ഓപ്പറേഷനുകൾ

ഓരോ എലമെന്റ് വൈസ് ഓപ്പറേഷനും ഇനിപ്പറയുന്നവ ചെയ്യുന്നു:

  1. രണ്ട് മാട്രിക്സുകൾക്കും ഒരേ അളവുകൾ ഉണ്ടെന്ന് ഉറപ്പാക്കുക
  2. ഓരോ അനുബന്ധ എലമെന്റിലും ഓപ്പറേഷൻ പ്രവർത്തിപ്പിക്കുക
  3. ഫലം ഔട്ട്പുട്ട് മാട്രിക്സിൽ ഇടുക

ലൂപ്പ് ഓരോ ഫംഗ്ഷനുകൾക്കും സമാനമായിരിക്കുമെന്ന് നമുക്ക് കാണാം, അതിനാൽ അതിനായി ഒരു മാക്രോ എഴുതാം

#define MAT_ELEMENTWISE_LOOP        \
  for (int i = 0; i < m1.rows; i++) \
    for (int j = 0; j < m1.cols; j++)

ബൗണ്ട്സ് പരിശോധിക്കുന്നതിനായി ഒരു ഫംഗ്ഷൻ, ബൗണ്ട്സ് പൊരുത്തപ്പെടുന്നില്ലെങ്കിൽ പാനിക് ചെയ്യുന്നു.

static inline void mat_bounds_check_elementwise(const matrix out,
                                                const matrix m1,
                                                const matrix m2) {
  if (DEBUG)
    if (m1.rows != m2.rows || m1.cols != m2.cols ||
        out.rows != m1.rows || out.cols != m1.cols) {
      fprintf(stderr,
              "എലമെന്റ് വൈസ് ഓപ്പറേഷന് അനുയോജ്യമല്ലാത്ത അളവുകൾ "
              "(%d, %d) & (%d, %d) => (%d, %d) \n",
              m1.rows, m1.cols, m2.rows, m2.cols, out.rows,
              out.cols);
      exit(1);
    }
}

ഇപ്പോൾ, നമുക്ക് കൂട്ടുക, ഗുണിക്കുക, ഹരിക്കുക, കുറയ്ക്കുക എന്നിവ നടപ്പിലാക്കണം. യഥാർത്ഥ കമ്പ്യൂട്ടേഷൻ ഒഴികെയുള്ള എല്ലാ കോഡും സമാനമായതിനാൽ, ഫംഗ്ഷൻ നിർവചിക്കുന്ന ഒരു മാക്രോയിലേക്ക് ഇത് അമൂർത്തമാക്കാം.

#define DEF_MAT_ELEMENTWISE_BUF(opname, op)           \
  static inline void matrix_##opname(                 \
      matrix out, const matrix m1, const matrix m2) { \
    mat_bounds_check_elementwise(out, m1, m2);        \
    MAT_ELEMENTWISE_LOOP {                            \
      mfloat x = matrix_get(m1, i, j);                \
      mfloat y = matrix_get(m2, i, j);                \
      matrix_set(out, i, j, op);                      \
    }                                                 \
  }

##opname ഫംഗ്ഷൻ പേരിലേക്ക് opname ന്റെ മൂല്യം ചേർക്കുന്നു.

മുന്നോട്ട് നോക്കുമ്പോൾ, എല്ലാ വ്യതിയാനങ്ങൾക്കും കൂട്ടുക, ഗുണിക്കുക, ഹരിക്കുക, കുറയ്ക്കുക എന്നിവ നിർവചിക്കേണ്ടതുണ്ടെന്ന് നമുക്കറിയാം, അതിനാൽ ഒരു നിശ്ചിത ഫംഗ്ഷൻ-നിർവചിക്കുന്ന-മാക്രോയ്ക്കായി അത് ചെയ്യുന്ന ഒരു മാക്രോ എഴുതാം

#define DEF_ALL_OPS(OP_MACRO) \
  OP_MACRO(sub, (x - y));     \
  OP_MACRO(add, (x + y));     \
  OP_MACRO(div, (x / y));     \
  OP_MACRO(mul, (x * y));

ഇപ്പോൾ നമുക്ക് യഥാർത്ഥത്തിൽ 4 ഫംഗ്ഷനുകൾ നിർവചിക്കാം!

DEF_ALL_OPS(DEF_MAT_ELEMENTWISE_BUF)

ബൂം! ഒരു വരിയിൽ, matrix_add, matrix_sub, matrix_div, matrix_mul എന്നീ ഫംഗ്ഷനുകൾ നിർവചിച്ചിരിക്കുന്നു.

ഇപ്പോൾ ഇൻ-പ്ലേസ് ഓപ്പറേഷനുകൾ നടപ്പിലാക്കാൻ ശ്രമിക്കാം.

static inline void mat_bounds_check_elementwise_ip(
    matrix m1, const matrix m2) {
  if (DEBUG)
    if (m1.rows != m2.rows || m1.cols != m2.cols) {
      fprintf(stderr,
              "എലമെന്റ് വൈസ് ഇൻ-പ്ലേസ് ഓപ്പറേഷന് അനുയോജ്യമല്ലാത്ത അളവുകൾ "
              "(%d, %d) & (%d, %d) \n",
              m1.rows, m1.cols, m2.rows, m2.cols);
      exit(1);
    }
}

#define DEF_MAT_ELEMENTWISE_IP(opname, op)                 \
  static inline void matrix_ip_##opname(matrix m1,         \
                                        const matrix m2) { \
    mat_bounds_check_elementwise_ip(m1, m2);               \
    MAT_ELEMENTWISE_LOOP {                                 \
      mfloat x = matrix_get(m1, i, j);                     \
      mfloat y = matrix_get(m2, i, j);                     \
      matrix_set(m1, i, j, op);                            \
    }                                                      \
  }

ഈ പുതിയ മാക്രോ ഉപയോഗിച്ച്, നമുക്ക് ഇത് ചെയ്യാം

DEF_ALL_OPS(DEF_MAT_ELEMENTWISE_IP)

ഇപ്പോൾ matrix_ip_add, matrix_ip_mul മുതലായവ നിർവചിച്ചിരിക്കുന്നു. ട്രാൻസ്പോസ് ഓപ്പറേഷനുകൾക്കും ഇത് ചെയ്യാം, പക്ഷേ ഇവിടെ കാണിക്കുന്നില്ല.

യൂനറി ഓപ്പറേഷനുകൾ

ചിലപ്പോൾ നമുക്ക് ഒരു മാട്രിക്സ് -യിൽ ഒരു യൂനറി ഓപ്പറേഷൻ ചെയ്യാൻ ആവശ്യമായി വരാം, ഉദാഹരണത്തിന് സ്കെയിലർ അല്ലെങ്കിൽ . നമുക്ക് ഓപ്പറേഷനിൽ ജനറിക് ആയ ഒരു യൂനറി ഫംഗ്ഷൻ ഉണ്ടാക്കാം.

#define DEF_MAT_UNARY_IP(opname, op)            \
  static inline void matrix_ip_##opname(matrix m1) { \
    MAT_ELEMENTWISE_LOOP {                           \
      mfloat x = matrix_get(m1, i, j);               \
      matrix_set(m1, i, j, op);                      \
    }                                                \
  }

DEF_MAT_UNARY_IP(square, (x * x))
DEF_MAT_UNARY_IP(negate, (-x))
DEF_MAT_UNARY_IP(sqrt, (sqrt(x)))

ഇപ്പോൾ matrix_ip_square(A), matrix_ip_negate(A), മുതലായവ നിർവചിച്ചിരിക്കുന്നു.

ഉപസംഹാരം

ഇങ്ങനെ, കുറഞ്ഞ പരിശ്രമത്തോടെ ഞങ്ങൾ ഒരു മാട്രിക്സ് ഓപ്പറേഷൻ ലൈബ്രറി “എഴുതി”. എന്നാൽ ഇത് ഒരു ചോദ്യം ഉയർത്തുന്നു: ഈ കോഡ് സുരക്ഷിതമാണോ?

നിങ്ങൾ നിർവചിച്ച എല്ലാ മാക്രോകളും #undef ചെയ്താൽ, അതെ, ഇത് നിങ്ങൾ സ്വയം എല്ലാ ഫംഗ്ഷനുകളും എഴുതുന്നതിന് സമാനമായി സുരക്ഷിതമാണ്. മറുവശത്ത്, നിങ്ങളുടെ ലൈബ്രറിയുടെ പ്രവർത്തനക്ഷമതയുടെ ഭാഗമായി മാക്രോകൾ എക്സ്പോസ് ചെയ്യുകയാണെങ്കിൽ, അത് ഇനി സുരക്ഷിതമായിരിക്കില്ല. എന്നാൽ ചില കോഡ് സുരക്ഷിതമാണെന്ന് അർത്ഥമാക്കുന്നില്ല നിങ്ങൾ അത് ചെയ്യണമെന്ന്. കോഡിൽ എന്തെങ്കിലും പ്രശ്നമുണ്ടെങ്കിൽ, അത് ഡീബഗ് ചെയ്യാൻ ബുദ്ധിമുട്ടായേക്കാം, കാരണം അത് എന്താണ് വികസിപ്പിച്ചെടുത്തതെന്ന് നിങ്ങൾക്ക് കാണാൻ കഴിയില്ല. അതിനാൽ ഒരു പ്രൊഡക്ഷൻ പരിസ്ഥിതിയിൽ, മാക്രോകളുടെ ഉപയോഗം ശക്തമായി നിരുത്സാഹപ്പെടുത്തുന്നു. ഒരു വിദ്യാഭ്യാസ പരമ്പരയ്ക്ക് ഇത് എളുപ്പമാർഗമായിരുന്നതിനാൽ ഞാൻ ഇവിടെ ഇത് ഉപയോഗിച്ചു.

നിങ്ങൾക്ക് എന്തെങ്കിലും ചോദ്യങ്ങളോ നിർദ്ദേശങ്ങളോ ഉണ്ടെങ്കിൽ, ഒരു അഭിപ്രായം ഇടാനോ എനിക്ക് ഒരു ഇമെയിൽ അയയ്ക്കാനോ മടിക്കേണ്ടതില്ല. വായിച്ചതിന് നന്ദി.

✦ No LLMs were used in the ideation, research, writing, or editing of this article.